diff --git a/README.md b/README.md index a7bc740..22bee76 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.0-beta.0 +npm install -g research-trellis@0.7.1-beta.0 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.0-beta.0 +npm install -g research-trellis@0.7.1-beta.0 research-trellis --version ``` @@ -197,7 +197,17 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update 详见[中文手册:Lean Research Closure](docs/manual/17-lean-research-closure.md)。 -### 6. 预检不是形式检查 +### 6. 五角色与轻量模式 + +五个正式 `Hermes`(科研工作流)角色现在是 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)。角色决定权限,`Profile`(模式)决定本次关注点;正式模板目前只提供给 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。 + +### 7. Agent Context Firewall + +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 + +返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 + +### 8. 预检不是形式检查 `hermes:preflight`(Hermes 预检)会检查模板文件、Python 编译、Hermes hook、门禁文档、沙箱配置、模板测试、类型检查和构建。 diff --git a/docs/hermes_deployment_preflight.md b/docs/hermes_deployment_preflight.md index 90baf7c..e483dba 100644 --- a/docs/hermes_deployment_preflight.md +++ b/docs/hermes_deployment_preflight.md @@ -14,8 +14,8 @@ pnpm --filter research-trellis hermes:preflight -- --quick 2. 发布前补跑完整验证: ```bash -pnpm --filter research-trellis exec vitest run test/templates/hermes-runtime.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts -PYTHONPYCACHEPREFIX=/tmp/research-trellis-pycache python3 -m py_compile packages/cli/src/templates/trellis/scripts/hermes/*.py packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +pnpm --filter research-trellis exec vitest run test/scripts/agent-context-firewall.integration.test.ts test/scripts/closure.integration.test.ts test/templates/hermes-runtime.test.ts test/templates/hermes-roles.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts +PYTHONPYCACHEPREFIX=/tmp/research-trellis-pycache python3 -m py_compile packages/cli/src/templates/trellis/scripts/common/*.py packages/cli/src/templates/trellis/scripts/hermes/*.py packages/cli/src/templates/shared-hooks/*.py pnpm --filter research-trellis typecheck pnpm --filter research-trellis build ``` @@ -24,6 +24,9 @@ pnpm --filter research-trellis build ## 安全 Gate +- 正式上下文防火墙只支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。Claude 的 `Agent`(代理)工具必须启用项目钩子;Codex 原生代理仅是建议性入口,需要强制控制时必须使用 `dispatch.py run --platform codex --mode strict`(Codex 严格派发)。 +- `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会覆盖心跳判断。`lean`(轻量)模式会显示强警告;`standard`(标准)模式要求新鲜钩子或严格执行心跳;`publication`(发表)模式缺少该硬门禁时必须拒绝派发和关闭。 +- Claude Agent 调用只传 `job_id`(工作编号),不允许异步调用。Codex 严格包装使用 `codex exec --output-schema --json -o`(结构化执行)并把原始跟踪保存在 `.trellis/.runtime/hermes-traces/`(本地跟踪目录);发布包和聊天只使用校验后的净化结果。 - `Bash` 写入守卫是最佳努力,不是完整 shell 沙箱。hook 会解析常见重定向和 `tee` 写入;遇到写入语义但无法明确解析目标时必须 `fail closed`。 - `runner.py` 必须按任务 `experiment.yaml` 的 `allowed_commands` 执行命令白名单;未匹配命令直接失败。`allowed_commands` is not a strong command sandbox(不是强命令沙箱);如果允许 `python3`,仍能执行任意 Python 行为。需要更强控制时,应使用更严格 exact match(精确匹配)或外部 OS 级隔离。 - `sandbox.required=true` 且 `mode=none` 必须拒绝运行;`mode=none` 只是本地执行,不是隔离环境。`container` 当前只提供 `Docker`(容器运行时)薄切片:通过 `docker run --rm`(一次性容器执行命令)挂载仓库,并在容器工作目录执行已通过白名单的命令;这仍是 `not an OS sandbox`(不是操作系统沙箱),也不是 `production ready`(生产就绪)的强隔离。`external` 当前仍只做可用性检查,不能声称已经提供 `OS sandbox`(操作系统沙箱)。 @@ -38,6 +41,7 @@ pnpm --filter research-trellis build - Bash gate 失败:改用受 Hermes runner 管理的命令,或把写入文件纳入任务卡 `allowed_files`。 - 命令不在 `allowed_commands`:更新任务级 `experiment.yaml`,不要绕过 runner。 - approval 失败:由外部 human/root 流程补写完整 approval 记录,agent 不能自行生成批准。 +- 防火墙状态异常:运行 `python3 ./.trellis/scripts/hermes/dispatch.py status --task `(派发状态),确认平台、环境变量和最新心跳;不能用手工改写心跳文件绕过 `standard`(标准)或 `publication`(发表)门禁。 ## 已知边界 diff --git a/docs/hermes_role_profile_migration.md b/docs/hermes_role_profile_migration.md new file mode 100644 index 0000000..46a606c --- /dev/null +++ b/docs/hermes_role_profile_migration.md @@ -0,0 +1,55 @@ +# Hermes 五角色迁移说明 + +## 变化 + +正式角色收敛为 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)。 + +`Role`(角色)决定权限、工具、输出契约和独立性要求;`Profile`(模式)决定本次工作的关注点。模式不会生成新的角色文档或平台代理模板。 + +正式五角色模板只提供给 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台继续保留原版通用代理和兼容行为。 + +## 旧名称 + +| 旧名称 | 规范化结果 | +| --- | --- | +| `scientist`(科学家) | `planner:research_design`(规划代理的研究设计模式) | +| `builder`(构建代理) | `coder:implementation`(编码代理的实现模式) | +| `literature`(文献代理) | `researcher:literature`(检索代理的文献模式) | +| `research/scout`(检索侦察代理) | 根据目标选择检索代理模式,默认 `codebase`(代码库) | +| `evaluator`(评估代理) | `reviewer:evidence`(复核代理的证据模式) | +| `claim-reviewer`(主张审查代理) | `reviewer:claim`(复核代理的主张模式) | +| `analyst`(分析代理) | 根据目标选择规划或复核模式,无法判断时使用 `planner:root_cause`(规划代理的根因模式) | + +旧任务卡读取时会给出弃用提示,但不会重写追加式历史。新任务卡写入时只保存正式角色和合法模式。 + +`evidence-curator`(证据整理代理)不再允许新派发。产物路径、哈希、运行清单和引用检查由 `evidence.py`(证据工具)确定性完成,证据质量判断由 `reviewer:evidence`(证据复核模式)完成。 + +## 派发上下文 + +`v0.7.1`(当前版本)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: + +1. `job_id`(工作编号)和 `hermes_revision`(Hermes 修订号); +2. 当前角色与模式; +3. 当前工作包或任务级空绑定; +4. 有界目标; +5. 最多 3 个直接引用。 + +完整需求、设计、历史报告、事件、账本和全部规范只在当前检查确实需要时读取。 + +## 测量 + +| 项目 | 改造前 | 改造后 | 变化 | +| --- | ---: | ---: | ---: | +| 角色文档 | 8 个 / 9673 字符 | 6 个 / 6819 字符 | 字符减少 29.5% | +| `Claude Code`(Claude 代码工具)正式 Hermes 模板 | 7 个 / 9873 字符 | 5 个 / 3865 字符 | 字符减少 60.9% | +| 两平台正式 Hermes 模板合计 | 7 个 / 9873 字符 | 10 个 / 8904 字符 | 新增 Codex 支持后仍减少 9.8% | +| 单个正式模板平均长度 | 1410 字符 | 890 字符 | 减少 36.9% | + +派发正文测试限制在 2000 字符内。`Claude Code`(Claude 代码工具)只向 `Agent`(代理)传 `job_id`(工作编号),`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)由统一 CLI 包装器执行。 + +## 兼容边界 + +- 原版任务、原版通用代理和非 closure 任务行为不变。 +- 已有 closure 任务缺少修订号时按 0 读取,下一次语义写入会补齐并递增。 +- 旧角色模板不再随新安装分发;已安装的旧别名只在 Hermes closure 任务中映射到正式角色和模式。 +- 证据、产物、主张、批准和任务事件的现有语义不变。 diff --git a/docs/hermes_trellis_research_transformation_plan.md b/docs/hermes_trellis_research_transformation_plan.md index 857170c..f275eed 100644 --- a/docs/hermes_trellis_research_transformation_plan.md +++ b/docs/hermes_trellis_research_transformation_plan.md @@ -199,7 +199,7 @@ ledger、local service queue、audit ledger、evaluation quality gate。这里 - 新增 `report.md`(报告) - 新增或模板化 `compare.jsonl`(对比记录) - 新增 aggregate/compare/report 命令 -- 扩展 reviewer/evaluator 角色模板 +- 使用 `reviewer:evidence`(证据复核模式)扩展证据审查 交付物: @@ -211,7 +211,7 @@ ledger、local service queue、audit ledger、evaluation quality gate。这里 验收标准: - 报告中的每个核心结论都能回链到 claim 和 evidence。 -- evaluator 不能修改源码、metric、split、baseline。 +- `reviewer:evidence`(证据复核模式)不能修改源码、指标、切分或基线。 - reviewer 只读 diff、record、evidence 和任务说明,不继承 coder 长对话。 主要风险: @@ -220,7 +220,7 @@ ledger、local service queue、audit ledger、evaluation quality gate。这里 ### P5:多代理科研协作层 -目标:把 coder、runner、evaluator、reviewer、literature 的协作固定成可复用模式。 +目标:把 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)的协作固定成可复用模式。 要改的范围: @@ -231,7 +231,7 @@ ledger、local service queue、audit ledger、evaluation quality gate。这里 交付物: -- 角色模板:scientist、coder、runner、evaluator、reviewer、literature。 +- 角色模板:一个公共契约和五个正式角色;具体工作由 `Profile`(模式)表达。 - 默认 `maker/checker`(写作者/审查者)分离。 - 同一 worktree 默认只有一个 active writer。 - 子代理任务卡强制包含 allowed_files、forbidden_files、timeout_at、heartbeat_interval、record_uri。 @@ -240,7 +240,7 @@ ledger、local service queue、audit ledger、evaluation quality gate。这里 - 没有 `task_card` 的子代理结果不被接受。 - coder 的输出必须经过 runner 和 reviewer 才能进入 review 状态。 -- reviewer/evaluator 的记录不能替代 human approval。 +- 任何复核记录都不能替代人工批准。 主要风险: @@ -303,13 +303,13 @@ P4 已完成: P5 已完成: -- [x] 角色模板覆盖 scientist、coder、runner、evaluator、reviewer、literature、evidence-curator、claim-reviewer。 +- [x] 角色模板收敛为 planner、researcher、coder、runner、reviewer;证据整理改为确定性工具。 - [x] bundled skill 和 Claude Hermes agents 已加入多代理科研协作入口。 - [x] `task_card` 强制包含 allowed_files、forbidden_files、timeout_at、heartbeat_interval、record_uri 等边界字段。 - [x] 没有 `task_card` 的结果记录会被拒收。 - [x] 同一 worktree 只允许一个未完成 coder/runner active writer。 - [x] coder 进入 review/claim_ready 前必须经过 runner 和 reviewer 记录。 -- [x] reviewer/evaluator 记录不能替代 `human/root` approval。 +- [x] reviewer 记录不能替代 `human/root` approval。 P6 部署前安全 gate: @@ -325,6 +325,15 @@ P6 部署前安全 gate: - [x] Hermes hook guard 已覆盖 `Edit`、`Write`、`MultiEdit` 和 `apply_patch`。 - [x] `MultiEdit` 会从当前 `tool_input`(工具输入)递归提取目标路径并按任务卡权限拦截。 +P7 Agent Context Firewall: + +- [x] 新增统一 `dispatch.py`(派发命令),支持创建、校验、显示、运行、列表、应用和状态查询。 +- [x] 派发绑定单调 `hermes_revision`(Hermes 修订号)、当前工作包、五角色模式和最多 3 个引用。 +- [x] Claude Agent 钩子只接受 `job_id`(工作编号),拒绝异步,并在结束时校验和净化结果。 +- [x] Codex native(Codex 原生)明确为建议性;strict(严格)复用结构化执行参数,不增加 SDK、数据库或服务。 +- [x] 原始跟踪只写入忽略发布的 `.trellis/.runtime/`(本地运行目录);主上下文只接收净化摘要。 +- [x] `lean`(轻量)、`standard`(标准)和 `publication`(发表)按钩子或严格模式心跳执行不同门禁。 + 最终验证记录: - [x] `pnpm --filter research-trellis exec vitest run test/templates/hermes-runtime.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts`:5 个文件、135 个测试通过。 diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index 1bda518..95cef6d 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,7 +11,7 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.7.0-beta.0`(测试版)。 +- 接受当前版本为 `0.7.1-beta.0`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 @@ -45,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "AGPL|Hermes|未实现" README.md docs/manual/appendix-f-feature-status.md`(静态核对)。 - 结果:项目定位、上游归属、实验性与未实现边界已对齐。 diff --git a/docs/manual/02-installation.md b/docs/manual/02-installation.md index aac99e7..4be1cbd 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.0-beta.0 +npm install --global research-trellis@0.7.1-beta.0 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.7.0-beta.0 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.1-beta.0 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.7.0-beta.0 +npm install --global research-trellis@0.7.1-beta.0 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.7.0-beta.0`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.1-beta.0`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.7.0-beta.0`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.1-beta.0`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index 4d293f5..96d4d69 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -23,7 +23,7 @@ git status --short research-trellis init -y -u "$USER" --codex --no-monorepo ``` -2. 检查生成结果和启动任务: +2. 检查生成结果和启动任务。空仓库先处理 `00-bootstrap-guidelines`(项目启动任务):完成一次研究合同讨论,填写 `.trellis/project/` 中的三份项目资料,再创建下面的首个任务。已有项目先读取 `PROJECT_INDEX.md`(事实索引)列出的原始资料,不把索引当作自动摘要。 ```bash python3 ./.trellis/scripts/task.py list @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "experiment.py|task.py start|--codex" packages/cli/src packages/cli/test`(实现与测试核对)。 - 结果:初始化、任务创建、实验校验和启动步骤与当前入口一致。 diff --git a/docs/manual/04-how-it-works.md b/docs/manual/04-how-it-works.md index f676ec5..5112f83 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "state_machine|approval-gate|task.py" packages/cli/src/templates`(协议与实现核对)。 - 结果:任务生命周期有实现支持;科研状态文件已准确标为协议模板。 diff --git a/docs/manual/05-project-setup.md b/docs/manual/05-project-setup.md index 0f8c61b..e60b741 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -55,7 +55,16 @@ research-trellis init --claude research-trellis init -u "$USER" ``` -5. 完成后检查: +5. 初始化会先区分两种情况: + +| 情况 | 生成内容 | 启动任务如何开始 | +| --- | --- | --- | +| 已有项目 | `PROJECT_INDEX.md`(事实索引)列出发现的文档、约定、配置、源码和记录路径 | 先读取索引中的原始资料,再填写项目背景、研究方案、项目约束和工程规范;索引不自动概括内容,也不生成研究结论 | +| 空仓库 | 同样生成项目资料目录,但索引明确没有既有事实 | 先进行一次聚焦讨论,确认问题、首个可验证结果、数据或输入、指标、资源约束和不做什么;然后创建首个可验证任务 | + +`.trellis/project/`(项目资料目录)包含 `BACKGROUND.md`(项目背景)、`RESEARCH_PLAN.md`(研究方案)、`CONSTRAINTS.md`(项目约束)和 `PROJECT_INDEX.md`(事实索引)。空仓库只填写已确认的信息,不要虚构研究方案。 + +6. 完成后检查: ```bash git status --short @@ -63,11 +72,11 @@ python3 ./.trellis/scripts/get_developer.py python3 ./.trellis/scripts/task.py list ``` -首次创建项目会生成 `00-bootstrap-guidelines`(规范启动任务);已有项目的新开发者会生成 `00-join-`(入项任务)。 +首次创建项目会生成 `00-bootstrap-guidelines`(项目启动任务)。空仓库中,它先完成研究合同讨论并创建首个可验证任务;已有项目中,它先建立可追溯的项目事实和工程规范。已有项目的新开发者会生成 `00-join-`(入项任务)。 ## 预期结果 -项目拥有 `.trellis/`(工作流目录)、至少一个平台目录、开发者身份、规范模板、科研模板和启动或入项任务。多包模式还会按包创建规范目录。 +项目拥有 `.trellis/`(工作流目录)、项目资料、至少一个平台目录、开发者身份、规范模板、科研模板和启动或入项任务。多包模式还会按包创建规范目录。 ## 失败恢复 @@ -79,7 +88,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "options.yes|registry|selectedTemplate" packages/cli/src/commands/init.ts packages/cli/test/commands/init.integration.test.ts`(分支核对)。 - 结果:默认、交互、指定模板、自定义源和 `-y`(自动确认)的失败行为已分开记录。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index 2cf3675..65dcbd1 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -91,7 +91,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "plan_change|decision_state|supersedes" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(变更协议核对)。 - 结果:提出、审阅、接受或拒绝、替代和校验流程已补全,并明示了结构校验边界。 diff --git a/docs/manual/07-spec-and-context.md b/docs/manual/07-spec-and-context.md index 9fdfd72..275badd 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -16,9 +16,11 @@ ## 操作步骤 -1. 补全 `.trellis/spec/`(项目规范目录)。单仓库通常包含 `backend/`(后端规范)、`frontend/`(前端规范)和 `guides/`(通用指南);多包仓库按包名组织。 +1. 补全 `.trellis/spec/`(项目规范目录)。单仓库通常包含 `backend/`(后端规范)、`frontend/`(前端规范)、`guides/`(通用指南)和 `languages/`(语言书写规范);多包仓库按包名组织。`guides/general-code-guidelines.md`(通用代码规范)是科研代码任务的默认候选基线,规定范围、测试、安全和可复现性;项目已有规范和语言工具配置优先。`languages/` 预置 TypeScript/JavaScript、Python、Go、Rust、C++ 和 Shell/Bash 规范,任务只显式加入当前改动涉及的文件。为保持最小上下文,通用规范和语言规范都不会自动加入每个任务;代码任务缺少更具体规范时,再显式加入实现或检查清单。 2. 规范应描述项目当前真实做法,并引用真实示例。初始化模板中的空白内容不是完成的规范。 -3. 查看可用包和当前上下文: +3. 主代理启动时只收到 `.trellis/project/`(项目资料目录)内项目背景、研究方案和项目约束的文档地址与用途。它在判断请求、讨论方案和拆分任务前按需读取;正文不会自动进入会话。派发时,任务明确引用优先,剩余位置才按角色补最小项目资料:代码角色默认拿项目约束,运行角色拿研究方案或约束,证据类审核拿研究方案和约束。代码规范、接口规范和实验材料仍由主代理明确加入派发包。 + +4. 查看可用包和当前上下文: ```bash python3 ./.trellis/scripts/get_context.py --mode packages @@ -28,7 +30,7 @@ python3 ./.trellis/scripts/closure.py capsule --task "$TASK" 新任务每轮优先使用 capsule(紧凑上下文):目标、范围、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。完整 PRD、报告、事件、证据账本、历史任务和非当前规范只在需要时读取。这个优化不删除原始文件。 -4. 向实现和检查清单追加需要的规范或研究材料: +5. 向实现和检查清单追加需要的规范或研究材料: ```bash python3 ./.trellis/scripts/task.py add-context "$TASK" implement .trellis/spec/backend/quality-guidelines.md "实现时遵守项目质量规则" @@ -44,7 +46,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" 目录条目会额外写入 `"type":"directory"`(目录类型)。创建任务时的 `_example`(示例)行不会被当作真实上下文;加入真实条目后可以删除该示例行。 -5. 不要把源码路径当作规范上下文堆进清单。源码由代理按任务范围读取;这里主要放规范和研究材料。 +6. 不要把源码路径当作规范上下文堆进清单。源码由代理按任务范围读取;这里主要放规范和研究材料。 ## 预期结果 @@ -60,7 +62,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "implement.jsonl|check.jsonl|context" packages/cli/src/templates/trellis/scripts packages/cli/test`(上下文实现核对)。 - 结果:上下文清单、重复检查和平台读取路径与当前模板一致。 diff --git a/docs/manual/08-hermes-records.md b/docs/manual/08-hermes-records.md index 8131900..b2e08b2 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "validate_artifact_file|artifact_ref_errors|has_symlink_component" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(路径与引用边界核对)。 - 结果:示例已改为先创建产物和摘要,再写产物与证据账本;路径检查范围已明示。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 82a329a..5c940a0 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -74,7 +74,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands|sandbox" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 - 结果:命令允许列表、隔离模式和对应测试均可定位。 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index eeed2e5..2d094cb 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "run_manifest|replay" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(运行清单与复查核对)。 - 结果:运行清单、输出摘要和复查失败分支均有实现与测试依据。 diff --git a/docs/manual/11-metrics-and-comparison.md b/docs/manual/11-metrics-and-comparison.md index 5e0cfa2..9a8927a 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality-gate|evidence_refs|claim_refs" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(门禁和引用核对)。 - 结果:质量门禁已有账本编号存在性检查以及有效、缺失引用测试。 diff --git a/docs/manual/12-reports-and-claims.md b/docs/manual/12-reports-and-claims.md index fafe902..61aeeef 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "claim-review|approval-gate" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(主张与审批边界核对)。 - 结果:主张审查和审批门禁入口可定位,报告与人工批准保持分离。 diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 93736bf..4f875e2 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -2,7 +2,7 @@ ## 目标 -为工作代理划定文件范围,使用心跳和检查点恢复长任务,并理解工具使用前门禁和结束门禁实际检查什么。 +用五个固定角色和轻量模式划定工作边界,减少重复模板和派发上下文,同时保留独立复核、心跳恢复和结束门禁。 ## 适用范围 @@ -11,27 +11,74 @@ ## 前置条件 - 已有活动任务和有效实验配置。 -- 已为每个工作代理准备唯一任务卡。 +- 已为每个工作代理准备唯一 validated dispatch(已验证派发)。 - 所选平台已正确安装并启用对应钩子。 ## 操作步骤 -1. 按工作形态选角色:`coder`(编码代理)负责授权文件修改,`runner`(运行代理)负责命令和测试,`reviewer`(复核代理)负责质量与安全检查,`evaluator`(评估代理)负责证据标准,`literature`(文献代理)负责来源整理。 -2. 每次派发前写 `task_card`(任务卡),至少限定 `allowed_files`(允许文件)、`forbidden_files`(禁止文件)、`worktree_id`(工作树编号)、心跳间隔、超时和恢复点。 +### 五个角色 + +`Role`(角色)决定权限、工具和责任边界。`Profile`(模式)决定这个角色本次具体做什么。模式不是新的子代理,也没有单独的平台代理模板。 + +| 角色 | 主要职责 | 默认模式 | 不能代替的工作 | +| --- | --- | --- | --- | +| `planner`(规划代理) | 研究设计、任务规划、根因分析、方法选择 | `task_planning`(任务规划) | 不改代码、不批准高风险科研变更、不关闭任务 | +| `researcher`(检索代理) | 文献、代码库、官方文档和已有方案检索 | `codebase`(代码库检索) | 不改代码、结果、主张或关闭状态 | +| `coder`(编码代理) | 代码、测试、配置和限定修复 | `implementation`(实现) | 不判断证据可信、不批准主张、不关闭任务 | +| `runner`(运行代理) | 实验、测试、构建和验证 | `validation`(验证) | 原则上不改核心代码,不把运行成功当作批准 | +| `reviewer`(复核代理) | 质量、证据、主张、安全、关闭和统计复核 | `quality`(质量复核) | 不改源代码、原始结果、指标值或人工批准记录 | + +支持的模式如下: + +- `planner`(规划代理):`research_design`(研究设计)、`task_planning`(任务规划)、`root_cause`(根因分析)、`method_selection`(方法选择)。 +- `researcher`(检索代理):`literature`(文献)、`codebase`(代码库)、`external_docs`(外部官方文档)、`prior_art`(已有方案)。 +- `coder`(编码代理):`implementation`(实现)、`tests`(测试)、`configuration`(配置)、`repair`(修复)。 +- `runner`(运行代理):`experiment`(实验)、`test`(测试)、`build`(构建)、`validation`(验证)。 +- `reviewer`(复核代理):`quality`(质量)、`evidence`(证据)、`claim`(主张)、`safety`(安全)、`closure`(关闭)、`statistics`(统计)。 + +三个最小示例: + +```yaml +role: planner +profile: research_design +``` + +```yaml +role: researcher +profile: literature +``` + +```yaml +role: reviewer +profile: evidence +``` + +### 派发与结果防火墙 + +1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包;任务级 `planner`(规划代理)和 `reviewer`(复核代理)可不绑定。 ```bash JOB="job-$(date -u +%Y%m%d-%H%M%S)-coder" -NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -TIMEOUT="$(date -u -d '+30 minutes' +%Y-%m-%dT%H:%M:%SZ)" -python3 ./.trellis/scripts/hermes/record.py append --task "$TASK" --record-type worker --json "{\"type\":\"task_card\",\"id\":\"tc-$JOB\",\"timestamp\":\"$NOW\",\"job_id\":\"$JOB\",\"role\":\"coder\",\"worktree_id\":\"main\",\"status\":\"queued\",\"allowed_files\":[\"src/**\",\"test/**\"],\"forbidden_files\":[\".env\",\"**/.env\"],\"heartbeat_interval\":\"5m\",\"timeout_at\":\"$TIMEOUT\",\"checkpoint\":\"not-started\",\"resume_from\":\"task_card\",\"record_uri\":\".trellis/tasks/$TASK/hermes/worker_records.jsonl\",\"evidence_refs\":[],\"risk_flags\":[]}" -python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind worker +python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" \ + --job-id "$JOB" --role coder --profile implementation \ + --work-package WP1 --objective "完成当前工作包" \ + --ref prd.md --allowed-file 'src/**' --allowed-file 'test/**' +python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id "$JOB" ``` -3. 使用平台的子代理工具派发。派发提示的第一行必须是 `Active task: .trellis/tasks/`(活动任务路径),随后写明角色、`JOB`(工作编号)、允许与禁止文件、期望输出、停止条件和需追加的记录。`Codex`(代码代理)默认走这条 `sub-agent`(子代理)路径。 +2. 派发正文最多 2000 字符、直接引用最多 3 个。绝对用户路径、敏感信息、越界或不存在的引用会被拒绝。任务状态变化后,旧派发会返回 `stale_dispatch`(过期派发)。 -4. 工作代理返回后先校验,再检查改动范围: +3. `Claude Code`(Claude 代码工具)的 `Agent`(代理)调用只传 `job_id`(工作编号),钩子会机械替换长提示,并禁止异步派发。`Codex`(代码代理平台)的 native(原生)代理仅为 advisory(建议性);需要硬门禁时运行: ```bash +python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" \ + --job-id "$JOB" --platform codex --mode strict +``` + +4. 返回必须是单个 `Result Envelope`(结果信封)JSON,包含 `uncertainties`(不确定项),其中 `conclusion`(结论)最多 1200 字符。非法 JSON、长日志、完整差异、搜索过程、敏感信息和越界改动会被拒绝;raw trace(原始跟踪)只保存在 `.trellis/.runtime/hermes-traces/`(本地运行目录)。 + +```bash +python3 ./.trellis/scripts/hermes/dispatch.py apply --task "$TASK" --job-id "$JOB" --result result.json python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind worker python3 ./.trellis/scripts/hermes/guard.py --task "$TASK" --job-id "$JOB" --changed-files results/metrics.json ``` @@ -49,15 +96,45 @@ python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" - 同一工作树不能同时存在多个未结束的 `coder`(编码代理)或 `runner`(运行代理); - 结果中的改动文件必须落在任务卡允许范围内,且不能命中禁止范围; - 配置了心跳间隔的结果需要已有检查点; -- 编码结果进入 `review`(复核)或 `claim_ready`(待审批)前,需要相关运行结果和复核记录。 +- `runner`(运行代理)成功只接受已有 `run_refs`(运行引用),不能把成功输出写成证据; +- `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录; +- 经 `Dispatch Packet`(派发包)确认的编码结果进入 `review`(复核)或 `claim_ready`(待审批)前,需要 `runner:test/build/validation`(运行代理的测试、构建或验证模式)结果,以及 `reviewer:quality/safety`(复核代理的质量或安全模式)记录。证据、主张或关闭复核不能替代代码质量复核。这个派发结果门禁不替代 `package-check`(工作包复核)或 `close`(关闭)的各自收口审计;未经过正式派发的旧任务继续按兼容流程处理。 7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。当前内置完整运行时门禁只在 `Claude Code`(Claude 代码工具)和已启用钩子的 `Codex`(代码代理)配置中注册。 -当前 `Stop`(结束)完成检查偏向代码任务:它要求编码结果覆盖当前非 `.trellis/`(工作流目录)差异,并有相关的成功运行清单和复核记录。纯研究任务也可能触发这个要求,这是测试版的已知限制。 +当前 `Stop`(结束)完成检查偏向代码任务:它要求编码结果覆盖当前非 `.trellis/`(工作流目录)差异,并有相关的成功运行清单和质量复核记录。纯研究任务也可能触发这个要求,这是测试版的已知限制。 + +### 证据整理工具 + +`evidence-curator`(证据整理代理)不再是可派发角色。机械工作由确定性命令完成: + +```bash +python3 ./.trellis/scripts/hermes/evidence.py collect --task "$TASK" +python3 ./.trellis/scripts/hermes/evidence.py validate --task "$TASK" +python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" +``` + +这些命令检查产物路径、`SHA-256`(哈希值)、运行清单关联、悬空引用、重复引用和缺失字段,并生成稳定的 `evidence_summary.json`(证据摘要)。它们不判断证据是否足以支持完成条件或主张;这部分由 `reviewer:evidence`(证据复核模式)完成。 + +### 旧角色迁移 + +旧任务卡仍可读取,校验时会给出一次弃用提示;新写入会保存正式角色和模式。 + +| 旧名称 | 新写法 | +| --- | --- | +| `scientist`(科学家) | `planner:research_design`(规划代理的研究设计模式) | +| `builder`(构建代理) | `coder:implementation`(编码代理的实现模式) | +| `literature`(文献代理) | `researcher:literature`(检索代理的文献模式) | +| `evaluator`(评估代理) | `reviewer:evidence`(复核代理的证据模式) | +| `claim-reviewer`(主张审查代理) | `reviewer:claim`(复核代理的主张模式) | +| `research/scout`(检索侦察代理) | 根据目标转为检索代理的文献、代码库或外部文档模式 | +| `analyst`(分析代理) | 根据目标转为规划代理的根因/方法模式,或复核代理的质量/证据/统计模式 | + +无法判断旧 `analyst`(分析代理)目标时,使用 `planner:root_cause`(规划代理的根因模式)并提示显式选择。旧 `evidence-curator`(证据整理代理)记录可以继续校验,但不能创建新的同名任务卡。 ## 预期结果 -每个修改和运行都能追溯到任务卡;长任务可以从检查点恢复;缺少运行或复核记录时,完成门禁按预期阻止结束。 +每个修改和运行都能追溯到正式角色、模式和任务卡;子代理默认只接收当前任务所需的最小上下文;长任务可以从检查点恢复;缺少运行或独立复核记录时,完成门禁按预期阻止结束。 ## 失败恢复 @@ -65,15 +142,18 @@ python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" - 改动超出范围:拒绝当前结果,扩大范围必须由协调者重新签发任务卡。 - 心跳超时:运行 `jobs.py check`(检查工作),从最新检查点创建替代工作,不要静默等待。 - 钩子未触发:检查平台配置;环境变量 `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会直接关闭门禁。 +- 角色或模式组合无效:只使用本页五个正式角色及其模式,不要把模式写成新角色。 +- 旧角色出现弃用提示:按迁移表更新派发配置;历史 `JSONL`(逐行 JSON)记录不需要重写。 +- 子代理读取内容过多:确认 Claude 只传 `job_id`(工作编号),或 Codex 使用 strict(严格)包装器;引用不能超过 3 个。 - 门禁与任务类型不匹配:保留失败记录并由人工决定调整流程,不要把门禁当作安全认证。 ## 验证记录 -- 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 日期:2026-07-16。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 -- 结果:`Claude Code`(Claude 代码工具)和 `Codex`(代码代理)的门禁注册、主代理防火墙和结束检查测试均可定位。 +- 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 - 未验证项:平台主机是否实际触发钩子仍取决于安装版本和用户配置。 ## 来源 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 5f16b0c..2ed39be 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -67,18 +67,20 @@ trust_level = "trusted" 重启 `Codex`(代码代理)后运行 `/hooks`(钩子审阅),逐项检查并批准 `.codex/hooks.json`(Codex 钩子配置)中的工作流注入和 `Hermes`(科研工作流)门禁。旧版 `Codex`(代码代理)使用 `codex_hooks = true`(旧钩子开关)。项目未信任、功能开关未开、未经 `/hooks`(钩子审阅)批准,或设置 `TRELLIS_HOOKS=0`(关闭钩子)、`TRELLIS_DISABLE_HOOKS=1`(禁用钩子)时,注入和门禁都不生效。 -`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变工作流提示路由,不会绕过已启用的 `PreToolUse`(工具使用前)门禁。门禁活跃时不要使用它要求主会话直接写入。 +`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会把 Hermes 正式角色升级为硬门禁。 -v0.7 的 closure 任务优先注入 Task Capsule(紧凑任务上下文),不默认注入完整 PRD、历史报告、事件和全部规范。Claude Code、Codex 和 OpenCode 使用相同字段与长度上限;Gemini、Qoder、CodeBuddy、Copilot 和 Factory Droid 通过共享每轮 hook 获得相同 Python 实现;Cursor 只在会话开始获得 capsule。Kiro、Kilo、Antigravity、Windsurf 和没有触发主会话 hook 的环境需要显式运行 `closure.py capsule`(生成紧凑上下文)。 +`v0.7.1`(当前版本)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 + +五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)依赖已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex native`(Codex 原生代理)仅为 `advisory`(建议性),`Codex strict`(Codex 严格模式)通过 `codex exec --output-schema --json -o`(结构化执行参数)执行。能力缺失时,`lean`(轻量)警告降级,`standard`(标准)必须有钩子或严格模式,`publication`(发表)拒绝派发和关闭。 6. 逐平台能力和限制如下。“子代理”表示仓库会安装对应代理模板;“门禁”专指 `hermes-runtime-guard.py`(Hermes 运行时门禁),不包括只做上下文注入的同名钩子事件。 | 平台 | 主会话上下文 | 子代理与上下文 | `PreToolUse / Stop`(门禁) | 启用条件与限制 | | --- | --- | --- | --- | --- | -| `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 支持;`Task / Agent`(代理工具)前推送任务上下文 | 两者都有 | 需平台读取项目 `settings.json`(设置);环境开关可关闭 | +| `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 正式 Hermes Agent 只传 `job_id`(工作编号),钩子替换提示并净化结果 | 完整 | 需平台读取项目设置;异步 Hermes Agent 被拒绝;环境开关可关闭 | | `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)注入 capsule 和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 没有每轮 capsule;只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | | `OpenCode`(开放代码工具) | `chat.message`(会话消息)注入会话摘要、每轮状态和 capsule | 支持;代理按派发首行自行读取 | 无 | 依赖 OpenCode 插件被实际加载;会话检索功能仍未实现 | -| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 支持;代理前置说明按派发首行拉取 | 两者都有 | 需用户级开关、项目信任和 `/hooks`(钩子审阅);默认子代理 | +| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | native(原生)仅建议;strict(严格)由统一派发 CLI 执行 | 写入和结束门禁;严格模式另有结果门禁 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅);发表模式必须使用硬门禁 | | `Kilo CLI`(Kilo 命令行) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | | `Kiro Code`(Kiro 代码工具) | 无通用主会话注入 | 支持;`agentSpawn`(代理创建)时推送 | 无 | 只注册代理创建钩子,无会话开始和每轮主会话钩子 | | `Gemini CLI`(Gemini 命令行) | `SessionStart + BeforeAgent`(会话开始和代理前) | 支持;代理前置说明拉取 | 无 | 依赖 `.gemini/settings.json`(Gemini 设置)的当前钩子协议 | @@ -104,10 +106,10 @@ v0.7 的 closure 任务优先注入 Task Capsule(紧凑任务上下文), ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 -- 结果:14 个平台的上下文注入、子代理、门禁和启用条件已与注册表及钩子配置对齐。 +- 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并区分 Codex native 与 strict。 - 未验证项:本轮未启动 14 种平台主机,也未访问远程工作流市场。 ## 来源 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index ab88d87..e580b59 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -27,12 +27,14 @@ research-trellis channel list --scope project 频道事件保存在用户目录下的 `.trellis/channels/`(频道存储),项目范围会按当前目录分桶,不写入项目的任务记录。 +频道消息和会话记忆都不是 `Hermes`(科研工作流)的事实源,也不能把任务改成 `closed`(已关闭)。正式角色的事实链必须经过账本和 validated result(已验证结果);原始代理输出只保存在 `.trellis/.runtime/hermes-traces/`(本地运行目录),不会自动进入频道、会话记忆或发布包。 + 2. 需要工作代理时,可启动当前仅支持的 `claude`(Claude 提供方)或 `codex`(Codex 提供方): ```bash -research-trellis channel spawn research-sync --scope project --provider codex --as evaluator --cwd "$PWD" -research-trellis channel send research-sync --scope project --as main --to evaluator "检查固定指标的证据引用" -research-trellis channel wait research-sync --scope project --as main --from evaluator --kind done --timeout 5m +research-trellis channel spawn research-sync --scope project --provider codex --as reviewer --cwd "$PWD" +research-trellis channel send research-sync --scope project --as main --to reviewer "使用 evidence 模式检查固定指标的证据引用" +research-trellis channel wait research-sync --scope project --as main --from reviewer --kind done --timeout 5m ``` `spawn`(启动工作代理)会创建后台监督进程。工作完成后用 `channel kill`(停止工作代理)结束仍在运行的进程;删除频道前先看[命令参考](appendix-a-cli-reference.md)。 @@ -64,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`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 f1af1f7..fd23c10 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.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dry-run|create-new|allow-downgrade" packages/cli/src/commands packages/cli/test/commands`(更新与恢复核对)。 - 结果:预览、冲突副本、降级拒绝和卸载预览分支均可定位。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 2c8292d..1969681 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`(任务状态)是当前状态的唯一事实源。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。重要变化追加到 `hermes/task-events.jsonl`(任务事件历史)。 +`task.json`(任务状态)是当前状态的唯一事实源。每次 Closure 语义写入都会原子替换文件并递增 `hermes_revision`(Hermes 修订号)。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。 ## Task Capsule @@ -57,8 +57,34 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit 每轮自动注入优先使用 capsule,不自动读取完整 `prd.md`(需求文档)、全部报告、全部事件、全部 ledger(账本)、历史任务和无关规范。这些文件不会删除,仍可按需读取。 +普通任务恢复仍可使用 `capsule`(紧凑上下文)。正式 `Hermes`(科研工作流)角色改为读取 validated dispatch(已验证派发):派发绑定当前修订号、角色、模式、工作包、目标和最多 3 个引用,正文不超过 2000 字符。 + Claude Code、Codex 和 OpenCode 使用同一 capsule 语义。其他具有共享每轮 hook 的平台也会收到 capsule;只有会话开始注入或没有主会话 hook 的平台,应在恢复任务时显式运行 `closure.py capsule`。 +## Agent Context Firewall + +正式防火墙只支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。派发文件位于 `.trellis/tasks//hermes/dispatches/.dispatch.json`(派发文件),净化结果位于同目录的 `.result.json`(结果文件)。 + +```bash +python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" \ + --role reviewer --profile closure --objective "检查当前关闭条件" +python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" +``` + +执行前会比较当前修订号,状态变化后的旧派发返回 `stale_dispatch`(过期派发)。结果必须包含 `uncertainties`(不确定项),结论最多 1200 字符;长日志、完整差异、搜索过程、敏感信息和绝对用户路径不会进入主上下文。两次非法返回只阻塞该 job(工作编号)和当前工作包,不会把科研任务标成完成。 + +## 角色与阶段 + +工作包仍是任务内部的结果分组,不因为使用不同角色自动变成子任务。常见顺序如下: + +1. `planner:task_planning`(规划代理的任务规划模式)提出 1–4 个工作包候选;状态变更仍由 closure 命令应用。 +2. `coder`(编码代理)完成需要修改代码、测试或配置的当前工作包。 +3. `runner`(运行代理)登记测试、构建、实验或验证结果。 +4. `reviewer`(复核代理)进行质量、证据或关闭复核;不同模式不能互相替代门禁。 +5. `researcher`(检索代理)只在规划或实现明确缺少代码库、文献或官方文档信息时加入。 + +`planner`(规划代理)不能直接把工作包标记为完成,`coder`(编码代理)不能批准主张,`runner`(运行代理)不能把命令成功当作证据批准,`reviewer`(复核代理)也不能伪造人工批准。 + ## 三种模式 | 模式 | 适合场景 | 关闭前增加的检查 | @@ -67,7 +93,9 @@ Claude Code、Codex 和 OpenCode 使用同一 capsule 语义。其他具有共 | `standard`(标准) | 需要可重复运行记录的正式项目阶段 | 成功运行清单、产物 hash、指标、证据、主张限制 | | `publication`(发表) | 正式论文证据和可公开主张 | 统计比较、主张处置、证据引用、`STATE.md`、`CLAIMS.md` 和人工批准 | -`run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)是不同状态。运行成功只能作为证据,不能代替任务关闭;任务关闭也不能代替论文主张批准。 +`run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)是不同状态。运行成功只生成运行记录,不能直接成为已接受证据;任务关闭也不能代替论文主张批准。 + +模式还会检查防火墙可用性:`lean`(轻量)在没有新鲜钩子或严格模式心跳时强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发和关闭。 ## Audit 与有限修复 @@ -134,8 +162,10 @@ python3 ./.trellis/scripts/closure.py handoff --task "$TASK" ## 常见失败 - `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 -- `package-done` 提示缺少证据:先运行实际验证,再通过 `--evidence` 登记命令、报告或产物引用。 +- `package-done` 提示缺少证据:先运行实际验证,再通过 `--evidence` 登记已有仓库文件或 `evidence_ledger.jsonl`(证据账本)中的证据编号;任意文字不会被接受为证据。 - `close` 提示工作包未处置:完成、defer 或 waive 对应工作包;不要直接改 JSON 状态。 +- `close` 提示派发未确认:先校验或处置对应结果;满足关闭条件后仍要显式运行 `audit`(审计)和 `close`(关闭)。 +- `stale_dispatch`(过期派发):当前状态已变化,按新修订号重新创建派发,不要复用旧结果。 - `repair` 达到上限:查看 `HANDOFF.md` 和阻塞原因,由人工决定停止任务,或批准提高 `max_repair_count` 后重新校验。 - `archive` 被拒绝:先运行 `closure.py audit` 和 `closure.py close`。旧任务没有 closure 字段时仍走原版兼容归档。 @@ -154,9 +184,9 @@ python3 ./.trellis/scripts/closure.py handoff --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 -- 结果:适度拆解、依赖环、capsule 长度、证据门禁、有限 repair、高风险 amend、三种模式、关闭和归档兼容均有自动化覆盖。 +- 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 ## 来源 diff --git a/docs/manual/README.md b/docs/manual/README.md index 15406ca..0bef677 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.0-beta.0`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.0`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 099f5f3..ef1b0d3 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.0-beta.0`(测试版)。 +适用于 `0.7.1`(测试版)。 ## 前置条件 @@ -50,7 +50,7 @@ python3 ./.trellis/scripts/closure.py next --task "$TASK" python3 ./.trellis/scripts/closure.py capsule --task "$TASK" python3 ./.trellis/scripts/closure.py package-start --task "$TASK" python3 ./.trellis/scripts/closure.py package-check --task "$TASK" -python3 ./.trellis/scripts/closure.py package-done --task "$TASK" --evidence "验证引用" +python3 ./.trellis/scripts/closure.py package-done --task "$TASK" --evidence artifacts/validation-output.txt python3 ./.trellis/scripts/closure.py package-block --task "$TASK" --reason "阻塞原因" python3 ./.trellis/scripts/closure.py amend --task "$TASK" --field --value --reason python3 ./.trellis/scripts/closure.py audit --task "$TASK" @@ -69,10 +69,27 @@ python3 ./.trellis/scripts/hermes/validate.py --help python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind worker python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind evidence python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim +python3 ./.trellis/scripts/hermes/evidence.py collect --task "$TASK" +python3 ./.trellis/scripts/hermes/evidence.py validate --task "$TASK" +python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" ``` +Agent Context Firewall(代理上下文防火墙)统一命令: + +```bash +python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" --role --profile --objective +python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id +python3 ./.trellis/scripts/hermes/dispatch.py show --task "$TASK" --job-id --prompt +python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py apply --task "$TASK" --job-id --result result.json +python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" +python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id +``` + +`create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)的 `Codex native`(Codex 原生)为建议性,`strict`(严格)才执行结构化包装;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 + 高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 ## 预期结果 @@ -86,7 +103,7 @@ python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "\.command\(|task.py|hermes/" packages/cli/src/cli packages/cli/src/templates/trellis/scripts`(常用入口核对)。 - 结果:本页列出的主命令、任务脚本和科研脚本入口均可定位。 diff --git a/docs/manual/appendix-b-directory-reference.md b/docs/manual/appendix-b-directory-reference.md index 1eede20..4c303fd 100644 --- a/docs/manual/appendix-b-directory-reference.md +++ b/docs/manual/appendix-b-directory-reference.md @@ -20,7 +20,21 @@ .trellis/ ├── config.yaml ├── workflow.md +├── project/ +│ ├── PROJECT_INDEX.md +│ ├── BACKGROUND.md +│ ├── RESEARCH_PLAN.md +│ └── CONSTRAINTS.md ├── spec/ +│ ├── guides/ +│ │ └── general-code-guidelines.md +│ └── languages/ +│ ├── typescript-javascript.md +│ ├── python.md +│ ├── go.md +│ ├── rust.md +│ ├── cpp.md +│ └── shell.md ├── tasks/ │ └── / │ ├── task.json @@ -38,6 +52,8 @@ ``` - `.trellis/hermes/`(全局科研模板)只保存约定和模板。 +- `.trellis/project/`(项目资料)保存项目背景、研究方案、约束和初始化时生成的事实索引。事实索引只列出原始资料路径,不自动总结内容。 +- `.trellis/spec/languages/`(语言书写规范)预置常用语言基线;任务只能按当前修改的语言选择引用,避免一次加载全部规则。 - `.trellis/tasks//hermes/`(任务科研记录)保存任务事实。 - `task.json`(任务状态)保存当前 closure 状态;`task-events.jsonl`(任务事件)只追加重要历史。 - `HANDOFF.md`(交接)按条件生成;`closure-report.md`(关闭报告)由通过的收口审计生成。 @@ -55,7 +71,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "tasks|hermes|workflow" packages/cli/src/constants packages/cli/src/configurators packages/cli/src/templates/trellis`(目录来源核对)。 - 结果:项目、任务和科研记录目录均与当前常量及模板对齐。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index e0ea771..261f943 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -23,9 +23,24 @@ | `session_auto_commit`(会话自动提交) | 设为 `false`(关闭)可避免脚本自动提交 | | `hooks`(生命周期钩子) | 任务创建、启动、结束和归档后的命令 | | `packages`(包列表) | 多包项目路径和类型 | -| `codex.dispatch_mode`(Codex 调度模式) | 缺省和非法值均为 `sub-agent`(子代理);`inline`(当前会话)只改变提示路由,不绕过已启用的 `PreToolUse`(工具前)门禁 | +| `codex.dispatch_mode`(Codex 调度模式) | 旧 Trellis 实现/检查代理的路由设置;不会把 Hermes 原生派发升级为强门禁 | -`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令和审批边界。`.trellis/tasks//hermes/experiment.yaml`(任务实验配置)定义问题、假设、数据、指标、随机种子、环境、允许命令、隔离模式和产物目录。 +`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令、审批边界和 `context_firewall`(上下文防火墙)。正式平台只有 `claude`(Claude)与 `codex`(Codex):Claude 依赖钩子强制替换,`Codex native`(Codex 原生派发)仅提供建议,`Codex strict`(Codex 严格模式)通过统一派发命令执行。 + +防火墙相关环境变量: + +| 环境变量 | 含义 | +| --- | --- | +| `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;`lean`(轻量)强警告,`standard`(标准)和 `publication`(发表)拒绝缺少强门禁的操作 | +| `TRELLIS_DISABLE_HOOKS=1`(禁用钩子) | 与上一项相同,用于兼容现有启动方式 | +| `TRELLIS_HOOKS_ACTIVE=1`(钩子活动标记) | 仅作状态提示;必须同时存在签名且新鲜的心跳,不能单独通过门禁 | +| `TRELLIS_PLATFORM`(平台) | 仅接受 `claude`(Claude)或 `codex`(Codex)作为正式防火墙平台 | +| `TRELLIS_CODEX_STRICT=1`(Codex 严格标记) | 仅作状态提示;严格包装仍需实际隔离执行和签名心跳,手工设置无效 | +| `TRELLIS_CONTEXT_FIREWALL_HEARTBEAT_TTL`(心跳有效期) | 新鲜心跳的秒数,默认 900 | + +`SessionStart`(会话启动)、运行守卫和 Codex 严格包装会在 `.trellis/.runtime/context-firewall/`(防火墙运行目录)写入心跳。`lean`(轻量)缺少新鲜心跳时继续但强警告;`standard`(标准)必须有钩子或严格执行;`publication`(发表)缺少硬门禁时拒绝派发和关闭。原始跟踪只写入 `.trellis/.runtime/hermes-traces/`(原始跟踪目录),默认不进入模板发布包。 + +`.trellis/tasks//hermes/experiment.yaml`(任务实验配置)定义问题、假设、数据、指标、随机种子、环境、允许命令、隔离模式和产物目录。 修改实验配置后运行: @@ -44,10 +59,10 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|session_auto_commit|allowed_commands" packages/cli/src/templates/trellis`(配置模板核对)。 -- 结果:会话、`Codex`(代码代理)调度和实验命令配置均可定位,默认子代理语义已对齐。 +- 结果:会话、Hermes 防火墙、`Codex`(代码代理)调度和实验命令配置均可定位,模式门禁与心跳设置已对齐。 - 未验证项:生命周期钩子中的用户自定义命令需在具体项目单独验证。 ## 来源 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index 469d7e1..bda7f7c 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -16,7 +16,7 @@ | 类型 | 必填字段摘要 | | --- | --- | -| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags` | +| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags`;`profile`(模式)可省略,写入时会补默认值 | | `heartbeat`(心跳) | `type,id,timestamp,job_id,status,checkpoint,summary,next_check_at` | | `checkpoint`(检查点) | `type,id,timestamp,job_id,checkpoint,resume_from,evidence_refs,open_items` | | `result`(结果) | `type,id,timestamp,job_id,status,summary,changed_files,evidence_refs,risk_flags,handoff` | @@ -28,6 +28,15 @@ | `human_approval`(人工批准) | `type,id,timestamp,claim_id,approver,decision,notes` | | `plan_change`(计划变更) | `type,id,timestamp,plan_ref,change_summary,reason,requested_by,decision_state,evidence_refs,supersedes` | +正式角色还有两种 JSON 文件,不是 `JSONL`(逐行 JSON): + +| 文件 | 关键字段 | +| --- | --- | +| `.dispatch.json`(派发文件) | `schema_version,job_id,task_id,hermes_revision,role,profile,work_package,objective,refs,allowed_files,forbidden_files,status,body,audit` | +| `.result.json`(净化结果) | `schema_version,job_id,status,conclusion,uncertainties,changed_files,evidence_refs,artifact_refs,run_refs,risk_flags,next_action,audit` | + +派发最多 3 个引用,正文最多 2000 字符;结果结论最多 1200 字符。`runner`(运行代理)结果的 `evidence_refs`(证据引用)必须为空,`reviewer:evidence/claim`(证据或主张复核)只能写 proposed judgment(提议判断)。raw trace(原始跟踪)位于 `.trellis/.runtime/hermes-traces/`(本地运行目录),不属于任务记录或发布包。 + 通用校验命令: ```bash @@ -38,6 +47,8 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim `run_manifest`(运行清单)不是普通类型记录,至少要有命令、工作目录、环境摘要、输入、输出、退出码、开始时间和结束时间。 +新任务卡的 `role`(角色)只允许 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)。`profile`(模式)必须属于对应角色。旧名称在读取时会规范化并提示弃用,历史文件不会被自动重写。 + ## 预期结果 逐行对象可解析,必填字段完整,引用的证据、产物和批准对象存在。 @@ -49,7 +60,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`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 68e8ce4..75e278d 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -21,13 +21,23 @@ | 任务启动提示缺少会话身份 | 在人工智能会话运行,或设置唯一 `TRELLIS_CONTEXT_ID`(会话标识) | | 任务启动提示 closure 未准备 | 先填写 intent 和完成定义,再运行 `closure.py plan` 与 `closure.py validate` | | 任务启动提示实验无效 | 填完 `experiment.yaml`(实验配置)后单独运行实验校验 | -| 工作包不能标记 done | 先运行 `package-check`,再用 `package-done --evidence` 登记真实验证引用 | +| 工作包不能标记 done | 先运行 `package-check`,再用 `package-done --evidence` 登记已有仓库文件或证据账本编号 | | audit 显示 has_gaps | 按第一条 package、missing 和 action 补齐;不要自动生成新计划 | | repair 后仍有缺口 | 完成 repair 打开的工作包;超过上限后由人工停止任务,或批准 amend `max_repair_count` 并重新 validate | | amend 提示需要批准 | 数据集、假设、切分、指标、基线、主张范围和 scope 变化需真实 `human/root` 批准 | | archive 提示 closure 未关闭 | 先运行 `closure.py audit` 和 `closure.py close`;旧任务无 closure 字段时不受此限制 | | 运行命令被拒绝 | 核对允许命令和任务卡,不要扩大为笼统命令 | | 工作代理记录无效 | 先校验必填字段、唯一任务卡和文件范围 | +| 提示角色或模式不匹配 | 按[工作代理与门禁](13-workers-and-gates.md)使用五个正式角色及其合法模式 | +| 旧角色出现弃用提示 | 更新后续任务卡;旧追加式记录保持原样,不要批量重写 | +| `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 修订号)已变化,重新创建派发 | +| 返回提示 `invalid_json`(非法 JSON)或 `long_log`(长日志) | 只返回结果信封 JSON;日志、差异和搜索过程保留在文件或本地原始跟踪中 | +| 两次返回无效后 job 被阻塞 | 修正返回契约并创建替代派发;不要把整个科研任务改成完成 | +| `standard`(标准)或 `publication`(发表)提示防火墙不可用 | 启用并批准 Claude/Codex 钩子,或使用 `Codex strict`(Codex 严格模式);发表模式不能降级到原生建议模式 | +| `close`(关闭)提示结果未确认 | 先处理所有派发结果和工作包,再显式运行 `audit`(审计)与 `close`(关闭) | | 质量门禁缺少统计字段 | 见[指标与比较](11-metrics-and-comparison.md)中的当前限制 | | 审批门禁失败 | 等待真实 `human/root`(人工根权限)批准记录 | | 记忆搜索提示 OpenCode 不可用 | 当前未实现,不是本地故障 | @@ -41,6 +51,7 @@ python3 ./.trellis/scripts/task.py current --source python3 ./.trellis/scripts/task.py list python3 ./.trellis/scripts/closure.py status --task "$TASK" python3 ./.trellis/scripts/closure.py capsule --task "$TASK" +python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" ``` ## 预期结果 @@ -54,7 +65,7 @@ python3 ./.trellis/scripts/closure.py capsule --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`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 96f70a7..bbd2105 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.0-beta.0`(测试版),后续版本应重新核对。 +适用于 `0.7.1`(测试版),后续版本应重新核对。 ## 前置条件 @@ -17,9 +17,15 @@ | 功能 | 状态 | 边界 | | --- | --- | --- | | 安装、初始化、更新、卸载预览 | 已实现 | 卸载实际执行会删除整个工作流目录 | +| 项目初始化闭环 | 已实现、实验性 | 区分空仓库与已有项目;事实索引只提供读取路径,不自动提取或确认项目事实 | | 任务、规范、上下文清单 | 已实现 | 规划质量仍需人工审阅 | | Lean Research Closure | 已实现、实验性 | 默认 1–4 个工作包;自动候选仍需人工审阅 | | Task Capsule 与条件式 Handoff | 已实现 | 平台没有主会话 hook 时需手动调用 | +| 五角色与 Profile | 已实现、实验性 | 正式平台代理模板当前只提供 Claude Code 和 Codex;旧任务卡可兼容读取 | +| Agent Context Firewall | 已实现、实验性 | 正式支持 Claude Code 和 Codex;Claude 依赖已批准钩子,Codex native 仅建议、strict 才强制 | +| 结构化 Result Envelope | 已实现 | 拒绝日志、完整差异、搜索过程和敏感信息;原始跟踪只保存在忽略发布的本地运行目录 | +| `hermes_revision`(Hermes 修订号) | 已实现 | Closure 语义写入原子递增;执行前拒绝过期派发 | +| 确定性证据整理 | 已实现 | 校验和汇总引用,不判断证据质量或批准主张 | | Closure Audit 与有限 Repair | 已实现 | 只处理缺口和状态,不自动运行实验或批准主张 | | 科研记录与结构校验 | 已实现、实验性 | 追加式约定,不防篡改 | | 科研状态流程 | 协议模板 | `state_machine.yaml`(状态协议)未作为通用运行时迁移引擎强制执行 | @@ -41,6 +47,7 @@ - `allowed_commands`(允许命令)和代理文件范围用于流程约束,不是操作系统级沙箱。 - `RecordBus`(记录总线)使用本地 `JSONL`(逐行 JSON)文件,便于审计,但不能防止有文件权限的进程篡改历史。 - 平台钩子可以未启用、被环境变量关闭或因平台差异失效。执行危险命令前仍需人工确认。 +- `lean`(轻量)缺少防火墙时只强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发与关闭。 - 不要把 `.env`(环境变量文件)、访问令牌、密码、私有数据或未脱敏实验输入写入任务、记录和报告,也不要提交到 `Git`(版本控制系统)。 - 外部文献、数据、模型和网络服务需要单独核对许可证、隐私和访问权限。 @@ -63,7 +70,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1`(测试版)。 - 更名前基准提交:`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 d570af9..207b634 100644 --- a/docs/manual/sources.md +++ b/docs/manual/sources.md @@ -40,7 +40,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.0-beta.0`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "^## |^# " docs/manual/*.md`(手册结构核对)。 - 结果:所有手册页的标题结构可读,来源索引仍指向当前仓库文件。 diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 3ab4ee2..f2fd9ee 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -93,6 +93,9 @@ work package 与 subtask 的边界:work package 属于同一 task 内部,适 # 规划与验证 python3 ./.trellis/scripts/closure.py plan --task \ --intent "可观察目标" --done-when "完成条件" +# 研究协议分流:探索任务需记录变化字段和已完成 grill +python3 ./.trellis/scripts/closure.py route --task --route exploration --research-change hypothesis +python3 ./.trellis/scripts/closure.py grill --task --complete python3 ./.trellis/scripts/closure.py validate --task # 每次只处理当前 package @@ -104,10 +107,23 @@ python3 ./.trellis/scripts/closure.py package-done --task --evidence "验 python3 ./.trellis/scripts/closure.py audit --task python3 ./.trellis/scripts/closure.py repair --task python3 ./.trellis/scripts/closure.py close --task + +# 正式角色派发:Claude 只传 job_id;Codex strict 由 CLI 执行 +python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective "任务" +python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id +python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict ``` 每轮 hook 优先注入约 500–1000 字符的 Task Capsule,只包含 intent、范围、当前 package、done_when、next action、blockers 和最多 3 个相关引用。完整 PRD、报告、事件、ledger、历史任务和非当前 spec 均保留,但只在需要时读取。Claude Code、Codex 与 OpenCode 使用相同 capsule 语义;没有每轮 hook 的平台需要显式运行 `closure.py capsule`。 +主代理启动时还会收到 `.trellis/project/` 的项目背景、研究方案和项目约束三个文档地址与用途,不注入正文。首次初始化还会提供 `PROJECT_INDEX.md`(事实索引):已有项目先按索引读取原始资料,再记录确认事实;空仓库先在 `00-bootstrap-guidelines` 中完成一次聚焦的研究合同讨论,再创建首个可验证任务。主代理在判断请求是否合理、是否需要讨论或如何拆分前按需读取;子代理只有在派发包明确引用时才读取其中一份文档。 + +派发包先保留任务明确引用和 context pin,再按角色和 Profile 补充最小项目资料,最多三个引用:代码角色默认补项目约束,实验运行补研究方案与约束,证据/主张/统计审核补研究方案与约束。更具体的代码规范或实验材料应由主代理作为任务引用明确加入。 + +科研任务按研究协议是否改变分流,不按任务大小。`delivery`(确定性交付)不改变研究协议,等价重构和性能优化留在这里;`execution`(已冻结协议的实验执行)只运行种子或预先批准的参数搜索;`exploration`(探索性研究)记录 hypothesis、model_architecture、dataset、split、preprocessing、objective/loss、metric_definition、baseline、claim_scope 中实际变化的字段,并在 validate 前记录已完成的 grill。新假设或模型功能架构变化属于高风险探索,必须创建独立探索任务(可挂同一父项目),不能伪装成普通 work package。 + +Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex native 仅为 advisory,strict 才机械执行和净化结果。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 + 阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 @@ -215,15 +231,15 @@ Complex task: ask the user if you can create a Trellis task and enter the planni [workflow-state:planning] -Hermes closure: use the injected Task Capsule first. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. -Load `trellis-brainstorm`; stay in planning. +Hermes closure: use the injected Task Capsule first. Before every plan or dispatch, the main agent must analyze intent, scope, completion conditions, route, risks, and the smallest useful work-package split. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. +Do not load `trellis-brainstorm` or `trellis-grill-me` by default. Use planning evidence directly for clear deterministic work; use brainstorming only for missing requirements, and grill only for a material unresolved solution, architecture, research-design, or scope decision, or when the user explicitly asks. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. -Planning order for this Claude Code path: `task.py create` → `trellis-brainstorm` → `trellis-grill-me` → development strategy decision. -`trellis-grill-me` is a required planning gate on this Claude Code path, not an optional suggestion. -Before `trellis-grill-me` is complete, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. +Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the same task-level review-gate set. [/workflow-state:planning] @@ -258,7 +274,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow only the current package in the Task Capsule. Move running -> review with `package-check`, record validation with `package-done`, then run `audit`; use at most the configured bounded `repair` rounds, and run `close` before archive. Do not load all reports, events, ledgers, history, or specs by default. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. diff --git a/packages/cli/README.md b/packages/cli/README.md index a7bc740..22bee76 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -97,7 +97,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.0-beta.0 +npm install -g research-trellis@0.7.1-beta.0 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.0-beta.0 +npm install -g research-trellis@0.7.1-beta.0 research-trellis --version ``` @@ -197,7 +197,17 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update 详见[中文手册:Lean Research Closure](docs/manual/17-lean-research-closure.md)。 -### 6. 预检不是形式检查 +### 6. 五角色与轻量模式 + +五个正式 `Hermes`(科研工作流)角色现在是 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)。角色决定权限,`Profile`(模式)决定本次关注点;正式模板目前只提供给 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。 + +### 7. Agent Context Firewall + +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 + +返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 + +### 8. 预检不是形式检查 `hermes:preflight`(Hermes 预检)会检查模板文件、Python 编译、Hermes hook、门禁文档、沙箱配置、模板测试、类型检查和构建。 diff --git a/packages/cli/package.json b/packages/cli/package.json index 7aa9dea..5a677c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis", - "version": "0.7.0-beta.0", + "version": "0.7.1-beta.0", "description": "Auditable AI-assisted research workflow tooling built on Trellis", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/scripts/hermes-preflight.js b/packages/cli/scripts/hermes-preflight.js index 71086b5..2ead1fc 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -26,8 +26,20 @@ const REQUIRED_FILES = [ "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", "packages/cli/src/templates/trellis/hermes/metrics/metrics_schema.yaml", "packages/cli/src/templates/trellis/hermes/reports/report.md", + "packages/cli/src/templates/trellis/hermes/roles/base.md", + "packages/cli/src/templates/trellis/hermes/roles/planner.md", + "packages/cli/src/templates/trellis/hermes/roles/researcher.md", + "packages/cli/src/templates/trellis/hermes/roles/coder.md", + "packages/cli/src/templates/trellis/hermes/roles/runner.md", + "packages/cli/src/templates/trellis/hermes/roles/reviewer.md", "packages/cli/src/templates/trellis/scripts/closure.py", "packages/cli/src/templates/trellis/scripts/common/closure.py", + "packages/cli/src/templates/trellis/scripts/common/roles.py", + "packages/cli/src/templates/trellis/scripts/common/dispatch.py", + "packages/cli/src/templates/trellis/scripts/common/firewall.py", + "packages/cli/src/templates/trellis/scripts/hermes/dispatch.py", + "packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json", + "packages/cli/src/templates/trellis/scripts/hermes/evidence.py", "packages/cli/src/templates/trellis/scripts/hermes/runner.py", "packages/cli/src/templates/trellis/scripts/hermes/report.py", "packages/cli/src/templates/trellis/scripts/hermes/service.py", @@ -43,6 +55,7 @@ const TEMPLATE_TEST_ARGS = [ "test/templates/claude.test.ts", "test/templates/codex.test.ts", "test/templates/shared-hooks.test.ts", + "test/scripts/agent-context-firewall.integration.test.ts", "test/scripts/closure.integration.test.ts", ]; @@ -314,7 +327,7 @@ export function runPreflight(options = {}) { if (quick) { checks.push( result("Template tests", true, [ - `${YELLOW}quick mode${RESET}: skipped; run pnpm --filter research-trellis exec vitest run test/templates/hermes-runtime.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts`, + `${YELLOW}quick mode${RESET}: skipped; run pnpm --filter research-trellis ${TEMPLATE_TEST_ARGS.join(" ")}`, ]), result("Typecheck", true, [ `${YELLOW}quick mode${RESET}: skipped; run pnpm --filter research-trellis typecheck`, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3460821..883be3a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -36,6 +36,11 @@ import { type ProjectType, type DetectedPackage, } from "../utils/project-detector.js"; +import { + collectProjectFactIndex, + renderProjectFactIndex, + type ProjectFactIndex, +} from "../utils/project-context.js"; import { initializeHashes, removeHash } from "../utils/template-hash.js"; import { NATIVE_WORKFLOW_ID, @@ -375,9 +380,13 @@ function ensureInitialTrellisSwitch(cwd: string, developerName: string): void { * task_store.py). */ function getBootstrapChecklistItems( + initializationKind: ProjectFactIndex["kind"], projectType: ProjectType, packages?: DetectedPackage[], ): string[] { + if (initializationKind === "blank") { + return ["完成一次项目级研究合同讨论", "创建一个可验证的首个研究任务"]; + } if (packages && packages.length > 0) { const items = packages.map((pkg) => `补充 ${pkg.name} 的开发规范`); items.push("补充代码示例"); @@ -393,27 +402,46 @@ function getBootstrapChecklistItems( } function getBootstrapRelatedFiles( + initializationKind: ProjectFactIndex["kind"], projectType: ProjectType, packages?: DetectedPackage[], ): string[] { + const projectFiles = [ + ".trellis/project/PROJECT_INDEX.md", + ".trellis/project/BACKGROUND.md", + ".trellis/project/RESEARCH_PLAN.md", + ".trellis/project/CONSTRAINTS.md", + ]; if (packages && packages.length > 0) { - return packages.map((pkg) => `.trellis/spec/${sanitizePkgName(pkg.name)}/`); + return [ + ...projectFiles, + ...packages.map((pkg) => `.trellis/spec/${sanitizePkgName(pkg.name)}/`), + ]; } if (projectType === "frontend") { - return [".trellis/spec/frontend/"]; + return [...projectFiles, ".trellis/spec/frontend/"]; } if (projectType === "backend") { - return [".trellis/spec/backend/"]; + return [...projectFiles, ".trellis/spec/backend/"]; } - return [".trellis/spec/backend/", ".trellis/spec/frontend/"]; + return [...projectFiles, ".trellis/spec/backend/", ".trellis/spec/frontend/"]; } function getBootstrapPrdContent( + initializationKind: ProjectFactIndex["kind"], projectType: ProjectType, pythonCmd: string, packages?: DetectedPackage[], ): string { - const checklistItems = getBootstrapChecklistItems(projectType, packages); + if (initializationKind === "blank") { + return getBlankProjectBootstrapPrd(pythonCmd); + } + + const checklistItems = getBootstrapChecklistItems( + initializationKind, + projectType, + packages, + ); const checklistMarkdown = checklistItems .map((item) => `- [ ] ${item}`) .join("\n"); @@ -427,13 +455,16 @@ function getBootstrapPrdContent( 出现在 \`.trellis/tasks/\` 下。当他们准备处理它时,应当在提供 Trellis 会话身份 的会话里启动这个任务。 -**你的目标**:帮助他们把团队真实的编码规范补充进 \`.trellis/spec/\`。 +**你的目标**:先根据已有项目事实建立项目资料,再帮助他们把团队真实的编码规范补充进 \`.trellis/spec/\`。 未来这个项目里的每次 AI 会话——包括 \`trellis-implement\` 和 \`trellis-check\` 子代理——都会自动加载每个任务 jsonl 清单里列出的 spec 文件。spec 为空, 子代理就会写出泛化代码;spec 真实完整,子代理才会贴近团队现有风格。 -不要一上来倾倒说明。先用一句简短欢迎语开场,确认仓库里是否已有约定文档 -(如 CLAUDE.md、.cursorrules 等),再以对话方式推进。 +先读取 \`.trellis/project/PROJECT_INDEX.md\`。它只列出初始化时发现的文件和目录,不是摘要。 +再按需读取其中的原始资料,把已确认事实写入 \`BACKGROUND.md\`(项目背景)、\`RESEARCH_PLAN.md\` +(研究方案)和 \`CONSTRAINTS.md\`(项目约束)。不确定的信息保留为空或标记待确认,不要推测。 + +不要一上来倾倒说明。先用一句简短欢迎语开场,再以对话方式推进。 --- @@ -583,13 +614,81 @@ ${pythonCmd} ./.trellis/scripts/task.py archive 00-bootstrap-guidelines return content; } +function getBlankProjectBootstrapPrd(pythonCmd: string): string { + return `# 启动任务:建立研究合同 + +**你(AI)正在执行这个任务,开发者不会直接阅读这个文件。** + +这是一个没有既有项目事实的空仓库。先进行一次聚焦的项目级讨论,不要直接开始写框架、创建大量子任务或猜测研究方向。 + +## 本任务的唯一目标 + +把已确认的信息写入 \`.trellis/project/\`,再创建一个范围小、可验证的首个研究任务。 + +讨论只需要确认六项: + +1. 研究问题或工程目标; +2. 首个可观察、可验证的结果; +3. 数据、输入或最小样例; +4. 指标、比较方式或验收条件; +5. 可用资源、隐私、安全和时间约束; +6. 本轮明确不做的内容。 + +把确认结果分别记录到 \`BACKGROUND.md\`(背景和目标)、\`RESEARCH_PLAN.md\`(问题、方法和证据要求)和 \`CONSTRAINTS.md\`(边界与限制)。不要把未经讨论的假设写成事实。 + +## 工作方式 + +- 这是一次需要方案讨论的启动任务。先完成聚焦讨论,再记录 \`closure.py grill --complete\`。 +- 讨论完成后,创建一个新的普通任务;它应有明确的 \`intent\`(目标)、\`definition_of_done\`(完成定义)和 1–4 个以结果为单位的工作包。 +- 首个任务应验证一个最小结果,例如固定样例上的可重复输出、基线比较或数据读取链路;不要把“搭完整平台”作为首个任务。 +- 启动任务只负责建立项目合同和首个任务,不负责实现该任务。 + +## 完成条件 + +- \`.trellis/project/\` 三份项目资料已填写已确认事实; +- 已创建一个可验证的首个任务,并完成其 \`closure.py plan\`; +- 不确定项和暂不处理范围已写入项目约束或新任务; +- 本任务的两个工作包都已登记完成或合理延期。 + +## 完成方式 + +\`\`\`bash +${pythonCmd} ./.trellis/scripts/closure.py grill --task 00-bootstrap-guidelines --complete +${pythonCmd} ./.trellis/scripts/closure.py validate --task 00-bootstrap-guidelines +${pythonCmd} ./.trellis/scripts/closure.py package-start --task 00-bootstrap-guidelines --package-id WP1 +${pythonCmd} ./.trellis/scripts/closure.py package-check --task 00-bootstrap-guidelines --package-id WP1 +${pythonCmd} ./.trellis/scripts/closure.py package-done --task 00-bootstrap-guidelines --package-id WP1 \\ + --evidence .trellis/project/BACKGROUND.md \\ + --evidence .trellis/project/RESEARCH_PLAN.md \\ + --evidence .trellis/project/CONSTRAINTS.md +FIRST_TASK="$(${pythonCmd} ./.trellis/scripts/task.py create "Verify the first sample" --slug first-sample)" +${pythonCmd} ./.trellis/scripts/closure.py plan --task "$FIRST_TASK" \\ + --intent "Verify one agreed minimal result" \\ + --done-when "The agreed sample has a reproducible result" +${pythonCmd} ./.trellis/scripts/closure.py package-start --task 00-bootstrap-guidelines --package-id WP2 +${pythonCmd} ./.trellis/scripts/closure.py package-check --task 00-bootstrap-guidelines --package-id WP2 +${pythonCmd} ./.trellis/scripts/closure.py package-done --task 00-bootstrap-guidelines --package-id WP2 \\ + --evidence "$FIRST_TASK/task.json" +${pythonCmd} ./.trellis/scripts/closure.py audit --task 00-bootstrap-guidelines +${pythonCmd} ./.trellis/scripts/closure.py close --task 00-bootstrap-guidelines +${pythonCmd} ./.trellis/scripts/task.py archive 00-bootstrap-guidelines +\`\`\` +`; +} + function getBootstrapTaskJson( developer: string, + initializationKind: ProjectFactIndex["kind"], projectType: ProjectType, packages?: DetectedPackage[], ): TaskJson { const today = new Date().toISOString().split("T")[0]; - const relatedFiles = getBootstrapRelatedFiles(projectType, packages); + const relatedFiles = getBootstrapRelatedFiles( + initializationKind, + projectType, + packages, + ); + const isBlank = initializationKind === "blank"; // Canonical 24-field shape via emptyTaskJson factory. // Checklist items (previously stored as structured `subtasks`) are now @@ -598,16 +697,122 @@ function getBootstrapTaskJson( return emptyTaskJson({ id: BOOTSTRAP_TASK_NAME, name: BOOTSTRAP_TASK_NAME, - title: "Bootstrap Guidelines", - description: "Fill in project development guidelines for AI agents", - status: "in_progress", + title: isBlank + ? "Establish Research Contract" + : "Establish Project Facts And Guidelines", + description: isBlank + ? "Discuss and record the project research contract, then create one verifiable first task" + : "Record project facts from existing sources and fill in development guidelines for AI agents", + status: "planning", dev_type: "docs", priority: "P1", creator: developer, assignee: developer, createdAt: today, relatedFiles, - notes: `First-time setup task created by research-trellis init (${projectType} project)`, + notes: `First-time ${initializationKind} project setup created by research-trellis init (${projectType} project)`, + intent: isBlank + ? "Establish the research contract and create a first verifiable task" + : "Establish factual project context and usable engineering guidelines", + in_scope: isBlank + ? [ + "Project background, research plan, constraints, first verifiable task", + ] + : ["Fact index, project context, existing engineering conventions"], + out_of_scope: isBlank + ? ["Full product implementation", "Unagreed research claims"] + : ["Inventing project facts", "Unrelated refactors"], + definition_of_done: isBlank + ? [ + "Confirmed project background, research plan, and constraints are recorded", + "A first verifiable task is created and planned", + ] + : [ + "Project context records cite facts from existing materials", + "Relevant engineering conventions are recorded with real examples", + ], + context_pins: [".trellis/project/PROJECT_INDEX.md"], + research_route: isBlank ? "exploration" : "delivery", + research_change_fields: isBlank ? ["hypothesis"] : [], + grill_completed: false, + work_packages: isBlank + ? [ + { + id: "WP1", + title: "Agree the project research contract", + outcome: + "Confirmed background, research plan, and constraints are recorded without invented facts.", + done_when: [ + "The six contract questions are answered or explicitly deferred", + "Confirmed project background, research plan, and constraints are recorded", + ], + evidence_required: [ + ".trellis/project/BACKGROUND.md", + ".trellis/project/RESEARCH_PLAN.md", + ".trellis/project/CONSTRAINTS.md", + ], + depends_on: [], + status: "pending", + evidence_refs: [], + blocker: null, + }, + { + id: "WP2", + title: "Create the first verifiable task", + outcome: + "One small task has a clear result and completion definition.", + done_when: [ + "A new task has intent and definition_of_done", + "A first verifiable task is created and planned", + ], + evidence_required: [".trellis/tasks//task.json"], + depends_on: ["WP1"], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ] + : [ + { + id: "WP1", + title: "Record factual project context", + outcome: + "Project background, plan, and constraints are grounded in indexed source materials.", + done_when: [ + "PROJECT_INDEX.md has been read", + "Project context files contain only confirmed facts or explicit unknowns", + ], + evidence_required: [ + ".trellis/project/PROJECT_INDEX.md", + ".trellis/project/BACKGROUND.md", + ".trellis/project/RESEARCH_PLAN.md", + ".trellis/project/CONSTRAINTS.md", + ], + depends_on: [], + status: "pending", + evidence_refs: [], + blocker: null, + }, + { + id: "WP2", + title: "Record usable engineering conventions", + outcome: + "Relevant specs reflect current repository patterns with real examples.", + done_when: [ + "Relevant project conventions are referenced", + "Specs distinguish current practice from future improvements", + ], + evidence_required: [".trellis/spec/"], + depends_on: ["WP1"], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ], + next_action: isBlank + ? "Hold one focused research-contract discussion, then record the completed grill." + : "Read PROJECT_INDEX.md and record only confirmed project facts.", + meta: { hermes_bootstrap_kind: initializationKind }, }); } @@ -618,11 +823,22 @@ function createBootstrapTask( cwd: string, developer: string, pythonCmd: string, + factIndex: ProjectFactIndex, projectType: ProjectType, packages?: DetectedPackage[], ): boolean { - const taskJson = getBootstrapTaskJson(developer, projectType, packages); - const prdContent = getBootstrapPrdContent(projectType, pythonCmd, packages); + const taskJson = getBootstrapTaskJson( + developer, + factIndex.kind, + projectType, + packages, + ); + const prdContent = getBootstrapPrdContent( + factIndex.kind, + projectType, + pythonCmd, + packages, + ); return writeTaskSkeleton(cwd, BOOTSTRAP_TASK_NAME, taskJson, prdContent); } @@ -1073,6 +1289,9 @@ export async function init(options: InitOptions): Promise { const cwd = process.cwd(); const isFirstInit = !fs.existsSync(path.join(cwd, DIR_NAMES.WORKFLOW)); + // Capture repository facts before .trellis/ and platform templates exist. + // The result is later saved as a reading map, never an inferred summary. + const projectFactIndex = collectProjectFactIndex(cwd); // Captured here (before createWorkflowStructure + init_developer run) so // the three-branch dispatch at the bottom can tell "fresh clone joiner" // (.trellis/ exists, .developer missing) apart from "creator first init". @@ -1840,6 +2059,11 @@ export async function init(options: InitOptions): Promise { workflowMdOverride, }); + await writeFile( + path.join(cwd, PATHS.PROJECT, "PROJECT_INDEX.md"), + renderProjectFactIndex(projectFactIndex), + ); + // Write monorepo packages to config.yaml (non-destructive patch) if (monorepoPackages) { writeMonorepoConfig(cwd, monorepoPackages); @@ -1940,6 +2164,7 @@ export async function init(options: InitOptions): Promise { cwd, developerName, pythonCmd, + projectFactIndex, projectType, monorepoPackages, ); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 622b7af..f836b96 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -42,8 +42,26 @@ import { configYamlTemplate, gitignoreTemplate, workflowMdTemplate, + projectBackgroundTemplate, + projectResearchPlanTemplate, + projectConstraintsTemplate, + projectReadmeTemplate, } from "../templates/trellis/index.js"; -import { agentsMdContent } from "../templates/markdown/index.js"; +import { + agentsMdContent, + guidesGeneralCodeGuidelinesContent, + languageGuidesIndexContent, + languageTypeScriptJavaScriptContent, + languagePythonContent, + languageGoContent, + languageRustContent, + languageCppContent, + languageShellContent, +} from "../templates/markdown/index.js"; +import { + collectProjectFactIndex, + renderProjectFactIndex, +} from "../utils/project-context.js"; import { ALL_MANAGED_DIRS, @@ -102,6 +120,7 @@ const PROTECTED_PATHS = [ `${DIR_NAMES.WORKFLOW}/.developer`, `${DIR_NAMES.WORKFLOW}/.current-task`, ]; +const RESEARCH_BASELINES_VERSION = "0.7.1-beta.0"; function getTrellisManagedBlock(content: string): string | null { const start = content.indexOf(TRELLIS_BLOCK_START); @@ -620,6 +639,7 @@ function collectTemplateFiles( * "Modified by you" conflict prompt — they can skip per-file there. */ bypassUpdateSkip = false, + addResearchBaselines = false, ): Map { const files = new Map(); const platforms = getConfiguredPlatforms(cwd); @@ -653,6 +673,9 @@ function collectTemplateFiles( // workspace/index.md stays excluded — it's runtime-appended by add_session.py // (journal index) and has no script-parsed structure. files.set(FILE_NAMES.AGENTS, buildAgentsMdTemplate(cwd)); + if (addResearchBaselines) { + addMissingResearchBaselines(cwd, files); + } // Platform-specific templates (only for configured platforms) for (const platformId of platforms) { @@ -692,6 +715,46 @@ function collectTemplateFiles( return files; } +/** Add missing research defaults for a pre-v0.7.1 project without rewriting user specs. */ +function addMissingResearchBaselines( + cwd: string, + files: Map, +): void { + const additions = new Map([ + [`${PATHS.PROJECT}/README.md`, projectReadmeTemplate], + [`${PATHS.PROJECT}/BACKGROUND.md`, projectBackgroundTemplate], + [`${PATHS.PROJECT}/RESEARCH_PLAN.md`, projectResearchPlanTemplate], + [`${PATHS.PROJECT}/CONSTRAINTS.md`, projectConstraintsTemplate], + [ + `${PATHS.PROJECT}/PROJECT_INDEX.md`, + renderProjectFactIndex(collectProjectFactIndex(cwd)), + ], + [ + `${PATHS.SPEC}/guides/general-code-guidelines.md`, + guidesGeneralCodeGuidelinesContent, + ], + [`${PATHS.SPEC}/languages/index.md`, languageGuidesIndexContent], + [ + `${PATHS.SPEC}/languages/typescript-javascript.md`, + languageTypeScriptJavaScriptContent, + ], + [`${PATHS.SPEC}/languages/python.md`, languagePythonContent], + [`${PATHS.SPEC}/languages/go.md`, languageGoContent], + [`${PATHS.SPEC}/languages/rust.md`, languageRustContent], + [`${PATHS.SPEC}/languages/cpp.md`, languageCppContent], + [`${PATHS.SPEC}/languages/shell.md`, languageShellContent], + ]); + const specRoot = path.join(cwd, PATHS.SPEC); + for (const [relativePath, content] of additions) { + if (relativePath.startsWith(`${PATHS.SPEC}/`) && !fs.existsSync(specRoot)) { + continue; + } + if (!fs.existsSync(path.join(cwd, relativePath))) { + files.set(relativePath, content); + } + } +} + /** * Analyze changes between current files and templates * @@ -1858,10 +1921,15 @@ export async function update(options: UpdateOptions): Promise { })(); // Collect templates (used for both migration classification and change analysis) + const addResearchBaselines = + cliVsProject > 0 && + projectVersion !== "unknown" && + compareVersions(projectVersion, RESEARCH_BASELINES_VERSION) < 0; const templates = collectTemplateFiles( cwd, codexUpgradeNeeded ? new Set(["codex"]) : undefined, breakingBypass, + addResearchBaselines, ); // Load update.skip paths (used for both safe-file-delete and template collection) diff --git a/packages/cli/src/configurators/workflow.ts b/packages/cli/src/configurators/workflow.ts index 0393e5d..502198b 100644 --- a/packages/cli/src/configurators/workflow.ts +++ b/packages/cli/src/configurators/workflow.ts @@ -32,6 +32,14 @@ import { guidesIndexContent, guidesCrossLayerThinkingGuideContent, guidesCodeReuseThinkingGuideContent, + guidesGeneralCodeGuidelinesContent, + languageGuidesIndexContent, + languageTypeScriptJavaScriptContent, + languagePythonContent, + languageGoContent, + languageRustContent, + languageCppContent, + languageShellContent, } from "../templates/markdown/index.js"; import { writeFile, ensureDir } from "../utils/file-writer.js"; @@ -77,7 +85,8 @@ export interface WorkflowOptions { * 2. Copying workflow.md and .gitignore (dogfooding) * 3. Creating workspace/ with index.md * 4. Creating tasks/ directory - * 5. Creating spec/ with templates (not dogfooded - generic templates) + * 5. Creating project/ context references + * 6. Creating spec/ with templates (not dogfooded - generic templates) * * @param cwd - Current working directory * @param options - Workflow options including project type @@ -131,6 +140,10 @@ export async function createWorkflowStructure( // Create tasks/ directory ensureDir(path.join(cwd, PATHS.TASKS)); + // Project context stays user-managed. SessionStart lists these references + // for the main agent without loading their full content. + await copyTrellisDir("project", path.join(cwd, PATHS.PROJECT)); + // Create spec templates based on project type // These are NOT dogfooded - they are generic templates for new projects if (packages && packages.length > 0) { @@ -225,6 +238,10 @@ async function createSpecTemplates( ensureDir(guidesDir); const guidesDocs: DocDefinition[] = [ { name: "index.md", content: guidesIndexContent }, + { + name: "general-code-guidelines.md", + content: guidesGeneralCodeGuidelinesContent, + }, { name: "cross-layer-thinking-guide.md", content: guidesCrossLayerThinkingGuideContent, @@ -238,6 +255,8 @@ async function createSpecTemplates( await writeFile(path.join(guidesDir, doc.name), doc.content); } + await writeLanguageGuides(cwd); + if (packages && packages.length > 0) { // Monorepo mode: create spec// for each package for (const pkg of packages) { @@ -253,3 +272,24 @@ async function createSpecTemplates( await writeSpecForType(path.join(cwd, PATHS.SPEC), projectType); } } + +/** Write compact language guides; callers select only the relevant one. */ +async function writeLanguageGuides(cwd: string): Promise { + const languagesDir = path.join(cwd, PATHS.SPEC, "languages"); + ensureDir(languagesDir); + const docs: DocDefinition[] = [ + { name: "index.md", content: languageGuidesIndexContent }, + { + name: "typescript-javascript.md", + content: languageTypeScriptJavaScriptContent, + }, + { name: "python.md", content: languagePythonContent }, + { name: "go.md", content: languageGoContent }, + { name: "rust.md", content: languageRustContent }, + { name: "cpp.md", content: languageCppContent }, + { name: "shell.md", content: languageShellContent }, + ]; + for (const doc of docs) { + await writeFile(path.join(languagesDir, doc.name), doc.content); + } +} diff --git a/packages/cli/src/constants/paths.ts b/packages/cli/src/constants/paths.ts index 7652fc1..6946671 100644 --- a/packages/cli/src/constants/paths.ts +++ b/packages/cli/src/constants/paths.ts @@ -17,6 +17,8 @@ export const DIR_NAMES = { ARCHIVE: "archive", /** Spec/guidelines directory (under .trellis/) */ SPEC: "spec", + /** Project-level background, research plan, and constraints (under .trellis/) */ + PROJECT: "project", /** Scripts directory (under .trellis/) */ SCRIPTS: "scripts", } as const; @@ -49,6 +51,8 @@ export const PATHS = { TASKS: `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.TASKS}`, /** .trellis/spec/ */ SPEC: `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.SPEC}`, + /** .trellis/project/ */ + PROJECT: `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.PROJECT}`, /** .trellis/scripts/ */ SCRIPTS: `${DIR_NAMES.WORKFLOW}/${DIR_NAMES.SCRIPTS}`, /** .trellis/.developer */ diff --git a/packages/cli/src/migrations/manifests/0.7.1-beta.0.json b/packages/cli/src/migrations/manifests/0.7.1-beta.0.json new file mode 100644 index 0000000..553a6e6 --- /dev/null +++ b/packages/cli/src/migrations/manifests/0.7.1-beta.0.json @@ -0,0 +1,9 @@ +{ + "version": "0.7.1-beta.0", + "description": "新增 Agent Context Firewall,以最小派发包、结构化结果和关闭门禁降低多代理上下文污染。", + "breaking": false, + "recommendMigrate": false, + "changelog": "**Agent Context Firewall:**\n- feat(dispatch): Hermes 子代理使用绑定任务修订、当前工作包和最多三个引用的 Dispatch Packet。\n- feat(result): 子代理以紧凑 Result Envelope 返回结论和证据引用,原始日志、diff 与搜索过程只保存为文件。\n- feat(claude): PreToolUse、SubagentStart、SubagentStop 和 PostToolUse 共同验证 Claude Code 的 Hermes 子代理输入和输出。\n- feat(codex): Codex native 模式保留协议级约束;strict 模式使用隔离工作区和结构化结果校验。\n- feat(gates): 未确认派发、未处置工作包或关闭条件未满足时,Stop 和 close 不再接受聊天声明。\n\n**兼容性:**\n- 五个正式角色和旧角色别名继续可读取;旧非 Hermes 任务维持原有 Trellis 行为。\n- 已有 closure 任务无需改写,更新模板后即可使用 dispatch 命令。", + "migrations": [], + "notes": "运行 research-trellis update 后会更新 Claude Code 与 Codex 模板、共享钩子和 Hermes dispatch 脚本。旧项目会补齐缺失的项目资料与科研代码规范,但不会覆盖已有项目资料或 spec。先使用 dispatch create 生成派发包;不要把完整会话、终端日志或完整 diff 直接传给子代理。" +} diff --git a/packages/cli/src/templates/claude/agents/hermes-claim-reviewer.md b/packages/cli/src/templates/claude/agents/hermes-claim-reviewer.md deleted file mode 100644 index aed22f4..0000000 --- a/packages/cli/src/templates/claude/agents/hermes-claim-reviewer.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: hermes-claim-reviewer -description: | - Hermes claim review gate. Checks whether task claims are supported by evidence and whether approval requirements are satisfied. -tools: Read, Bash, Glob, Grep -model: opus ---- -# Hermes Claim Reviewer Agent - -You are the `hermes-claim-reviewer` gate. Review claims and report whether they are ready for human approval. - -## Required Context - -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before reviewing: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/claim-reviewer.md` -- `.trellis/tasks//prd.md` -- `.trellis/tasks//design.md` if present -- `.trellis/tasks//check.jsonl` if present -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` evidence, claim, approval, transition, review, and subagent records - -## Review Rules - -- Treat Hermes JSONL files as append-only records. -- Use the task card boundary, `allowed_files`, and `forbidden_files` as part of the review. -- Verify each claim links to evidence record ids. -- Verify the state path includes `planning`, `running`, `review`, and `claim_ready` before approval is discussed. -- `claim_ready` means ready for human review, not approved. -- `approved` requires a human approval record that references the claim id. -- `HumanGate` stays open until that human approval record exists. - -## Must Not - -- Do not write or simulate a human approval record. -- Do not modify evidence or claim records during review. -- Do not treat reviewer agreement as approval. -- Do not approve claims without a human approval record. diff --git a/packages/cli/src/templates/claude/agents/hermes-coder.md b/packages/cli/src/templates/claude/agents/hermes-coder.md index e490520..d90d065 100644 --- a/packages/cli/src/templates/claude/agents/hermes-coder.md +++ b/packages/cli/src/templates/claude/agents/hermes-coder.md @@ -1,37 +1,13 @@ --- name: hermes-coder -description: | - Hermes coder. Edits source inside a task-card boundary and records the work in worker records. -tools: Read, Write, Bash, Glob, Grep -model: opus +description: Hermes coder for bounded implementation, tests, configuration, and repair. +tools: Read, Write, Edit, Bash, Glob, Grep +permissionMode: acceptEdits --- -# Hermes Coder Agent +# Hermes Coder -You are the `hermes-coder` agent. Your job is to change code within the active task boundary and report the work in `worker_records.jsonl`. +Use only the validated dispatch body injected by `PreToolUse(Agent)`. It contains the job, revision, role/profile, current package, objective, and at most three refs. Do not use the original Agent prompt or load full task history. -## Required Context +Modify only `allowed_files` and avoid `forbidden_files`. Return exactly one Result Envelope JSON object with `uncertainties`; never include logs, diffs, search history, secrets, or absolute user paths. Do not approve evidence or claims, mark closure, or widen scope. -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before editing: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/coder.md` -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use a task card before any edit or result. -- Keep edits inside `allowed_files` and avoid `forbidden_files`. -- Keep one active writer per worktree. -- Append checkpoints and results to `worker_records.jsonl`. -- Stop at `HumanGate`; local completion is not approval. - -## Must Not - -- Do not edit outside the task card boundary. -- Do not treat chat-only summaries as acceptance. -- Do not create approval records. -- Do not accept a worker result without a task card. +Do not spawn another sub-agent. Independent execution and review belong to runner and reviewer. diff --git a/packages/cli/src/templates/claude/agents/hermes-evaluator.md b/packages/cli/src/templates/claude/agents/hermes-evaluator.md deleted file mode 100644 index b23f552..0000000 --- a/packages/cli/src/templates/claude/agents/hermes-evaluator.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: hermes-evaluator -description: | - Hermes evaluator. Checks outputs against the requested evidence standard and writes structured worker records. -tools: Read, Bash, Glob, Grep -model: opus ---- -# Hermes Evaluator Agent - -You are the `hermes-evaluator` agent. Your job is to compare outputs, measurements, and evidence against the task card standard. - -## Required Context - -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before evaluating: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/evaluator.md` -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use a task card before producing an evaluation. -- Compare results against the requested method and acceptance criteria. -- Append checkpoints, results, and risks to `worker_records.jsonl`. -- Keep the evaluation traceable to concrete files, commands, outputs, or measurements. -- Stop at `HumanGate`; evaluator confidence is not approval. - -## Must Not - -- Do not edit source files. -- Do not create approval records. -- Do not treat coder or runner long context as evidence. -- Do not accept a worker result without a task card. diff --git a/packages/cli/src/templates/claude/agents/hermes-literature.md b/packages/cli/src/templates/claude/agents/hermes-literature.md deleted file mode 100644 index ea7ce38..0000000 --- a/packages/cli/src/templates/claude/agents/hermes-literature.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: hermes-literature -description: | - Hermes literature researcher. Finds papers, citations, and source trails for a task. -tools: Read, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Hermes Literature Agent - -You are the `hermes-literature` agent. Your job is to gather outside sources and record a traceable source trail for the task. - -## Required Context - -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before searching: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/literature.md` -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use a task card before any source gathering. -- Record source titles, citations, and limits in worker records. -- Keep the source trail traceable to concrete documents or citations. -- Stop at `HumanGate`; source notes do not become approval. - -## Must Not - -- Do not treat chat-only summaries as sources. -- Do not invent citations or source ids. -- Do not create approval records. -- Do not accept a worker result without a task card. diff --git a/packages/cli/src/templates/claude/agents/hermes-planner.md b/packages/cli/src/templates/claude/agents/hermes-planner.md new file mode 100644 index 0000000..88e140b --- /dev/null +++ b/packages/cli/src/templates/claude/agents/hermes-planner.md @@ -0,0 +1,12 @@ +--- +name: hermes-planner +description: Hermes planner for research design, bounded task planning, root-cause analysis, and method selection. +tools: Read, Glob, Grep +--- +# Hermes Planner + +Use only the validated dispatch body injected by `PreToolUse(Agent)`. It contains the job, revision, role/profile, objective, and at most three refs. Task-level planning may use `work_package: null`. + +Return exactly one Result Envelope JSON object with a bounded conclusion, `uncertainties`, blockers, and next action. You may propose changes, but must not edit code, mark packages done, approve high-risk changes or claims, or close the task. + +Do not spawn another sub-agent. Record recommendations through the task-card output requested by the main agent. diff --git a/packages/cli/src/templates/claude/agents/hermes-researcher.md b/packages/cli/src/templates/claude/agents/hermes-researcher.md new file mode 100644 index 0000000..0dae556 --- /dev/null +++ b/packages/cli/src/templates/claude/agents/hermes-researcher.md @@ -0,0 +1,12 @@ +--- +name: hermes-researcher +description: Hermes researcher for literature, codebase, official documentation, and prior-art searches. +tools: Read, Bash, Glob, Grep, WebSearch, WebFetch +--- +# Hermes Researcher + +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. + +Do not spawn another sub-agent. Write only source notes or worker records explicitly allowed by the task card. diff --git a/packages/cli/src/templates/claude/agents/hermes-reviewer.md b/packages/cli/src/templates/claude/agents/hermes-reviewer.md index 1e295ed..be79158 100644 --- a/packages/cli/src/templates/claude/agents/hermes-reviewer.md +++ b/packages/cli/src/templates/claude/agents/hermes-reviewer.md @@ -1,37 +1,12 @@ --- name: hermes-reviewer -description: | - Hermes reviewer. Checks diffs, records, and scope before handoff. +description: Hermes independent reviewer for quality, evidence, claims, safety, closure, and statistics. tools: Read, Bash, Glob, Grep -model: opus --- -# Hermes Reviewer Agent +# Hermes Reviewer -You are the `hermes-reviewer` agent. Your job is to review diffs and records before the main session accepts a handoff. +Use only the validated blind-review dispatch body injected by `PreToolUse(Agent)`. Read current artifacts, criteria, ledgers, and cited refs only. Do not read coder/runner explanations or worker result prose. -## Required Context +Return exactly one Result Envelope JSON object with `uncertainties`. Evidence/claim profiles must include only a proposed `review_judgment`; they cannot approve facts, claims, closure, or human records. -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before reviewing: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/reviewer.md` -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use a task card before any review result. -- Review changed files, evidence refs, and worker records against the task scope. -- Append review notes or rejection records when the handoff is not ready. -- Keep the review focused on the diff and recorded evidence. -- Stop at `HumanGate`; reviewer agreement is not approval. - -## Must Not - -- Do not edit source files. -- Do not create approval records. -- Do not inherit coder or runner long conversation as the basis for judgment. -- Do not accept a worker result without a task card. +Do not spawn another sub-agent. Preserve independent review. diff --git a/packages/cli/src/templates/claude/agents/hermes-runner.md b/packages/cli/src/templates/claude/agents/hermes-runner.md index 5b424ab..c160a0d 100644 --- a/packages/cli/src/templates/claude/agents/hermes-runner.md +++ b/packages/cli/src/templates/claude/agents/hermes-runner.md @@ -1,37 +1,12 @@ --- name: hermes-runner -description: | - Hermes runner. Executes commands, tests, and measurements for a task-card-bound worker. +description: Hermes runner for experiments, tests, builds, and validation with reproducible records. tools: Read, Bash, Glob, Grep -model: opus --- -# Hermes Runner Agent +# Hermes Runner -You are the `hermes-runner` agent. Your job is to run the commands the task card authorizes and write structured worker records. +Use only the validated dispatch body injected by `PreToolUse(Agent)`. Follow its role/profile limits and run only commands authorized for its current work package; do not read unrelated history. -## Required Context +Register exact commands and output in run manifests and artifacts. Return exactly one Result Envelope JSON object with existing `run_refs`, `uncertainties`, and empty `evidence_refs`; runner success is not evidence approval or closure. -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before running commands: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/runner.md` -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use a task card before running commands. -- Keep command work inside the active task boundary. -- Append heartbeats, checkpoints, and results to `worker_records.jsonl`. -- Report the exact command or measurement source for each result. -- Stop at `HumanGate`; a successful run is not approval. - -## Must Not - -- Do not edit source files unless the task card explicitly allows it. -- Do not treat chat output as evidence. -- Do not create approval records. -- Do not accept a worker result without a task card. +Do not spawn another sub-agent. Return implementation defects to a coder. diff --git a/packages/cli/src/templates/claude/agents/hermes-scientist.md b/packages/cli/src/templates/claude/agents/hermes-scientist.md deleted file mode 100644 index 11136b0..0000000 --- a/packages/cli/src/templates/claude/agents/hermes-scientist.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: hermes-scientist -description: | - Hermes research scientist. Frames research tasks, records evidence-backed claims, and keeps task records aligned with the Hermes state machine. -tools: Read, Write, Bash, Glob, Grep, mcp__exa__web_search_exa, mcp__exa__get_code_context_exa -model: opus ---- -# Hermes Scientist Agent - -You are the `hermes-scientist` agent. Your job is to help a Trellis task produce research records that can later support claims. - -## Required Context - -First resolve the active task from the dispatch prompt or by running `python3 ./.trellis/scripts/task.py current --source`. - -Read these files before making or updating research records: - -- `.trellis/hermes/config.yaml` -- `.trellis/hermes/state_machine.yaml` -- `.trellis/hermes/roles/scientist.md` -- `.trellis/tasks//prd.md` -- `.trellis/tasks//design.md` if present -- `.trellis/tasks//implement.md` if present -- `.trellis/tasks//hermes/worker_records.jsonl` -- `.trellis/tasks//hermes/` existing records if present - -## Work Rules - -- Use append-only records. Never rewrite, reorder, truncate, or delete Hermes JSONL records. -- Evidence must point to concrete files, commands, outputs, citations, or measurements. -- Keep `allowed_files` and `forbidden_files` aligned with the active task card. -- Use a task card before dispatching or accepting any worker result. -- Claims must link to evidence record ids and state scope limits. -- `claim_ready` is only a proposed state. `approved` requires a human approval record. -- Stop at `HumanGate`; do not treat local confidence as approval. - -## Must Not - -- Do not treat chat output as evidence. -- Do not create approval records. -- Do not claim a result is approved without a human approval record. -- Do not accept a worker result without a task card. -- Do not bypass the `planning -> running -> review -> claim_ready -> approved` flow. diff --git a/packages/cli/src/templates/claude/settings.json b/packages/cli/src/templates/claude/settings.json index b32b891..b278e6b 100644 --- a/packages/cli/src/templates/claude/settings.json +++ b/packages/cli/src/templates/claude/settings.json @@ -65,6 +65,16 @@ "timeout": 15 } ] + }, + { + "matcher": "Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "{{PYTHON_CMD}} .claude/hooks/hermes-runtime-guard.py", + "timeout": 15 + } + ] } ], "UserPromptSubmit": [ @@ -99,6 +109,30 @@ } ] } + ], + "SubagentStart": [ + { + "matcher": "hermes-planner|hermes-researcher|hermes-coder|hermes-runner|hermes-reviewer|hermes-scientist|hermes-literature|hermes-evaluator|hermes-claim-reviewer|trellis-implement|trellis-check|trellis-research", + "hooks": [ + { + "type": "command", + "command": "{{PYTHON_CMD}} .claude/hooks/hermes-runtime-guard.py", + "timeout": 15 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "{{PYTHON_CMD}} .claude/hooks/hermes-runtime-guard.py", + "timeout": 30 + } + ] + } ] }, "enabledPlugins": {} diff --git a/packages/cli/src/templates/codex/agents/hermes-coder.toml b/packages/cli/src/templates/codex/agents/hermes-coder.toml new file mode 100644 index 0000000..ecd9aa2 --- /dev/null +++ b/packages/cli/src/templates/codex/agents/hermes-coder.toml @@ -0,0 +1,17 @@ +name = "hermes-coder" +description = "Hermes coder for bounded implementation, tests, configuration, and repair." +sandbox_mode = "workspace-write" + +developer_instructions = """ +You are the Hermes coder. Do not spawn another sub-agent. + +Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: coder:`, current work package, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. + +Modify only `allowed_files`, avoid `forbidden_files`, and keep one active writer per worktree. Return exactly the Result Envelope JSON; never include logs, diffs, or search history. Do not approve evidence or claims, close the task, or widen scope without an amended dispatch. +""" + +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/packages/cli/src/templates/codex/agents/hermes-planner.toml b/packages/cli/src/templates/codex/agents/hermes-planner.toml new file mode 100644 index 0000000..b56e32d --- /dev/null +++ b/packages/cli/src/templates/codex/agents/hermes-planner.toml @@ -0,0 +1,17 @@ +name = "hermes-planner" +description = "Hermes planner for research design, bounded task planning, root-cause analysis, and method selection." +sandbox_mode = "read-only" + +developer_instructions = """ +You are the Hermes planner. Do not spawn another sub-agent. + +Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: planner:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. + +Follow the active Profile and return exactly the Result Envelope JSON with a bounded conclusion, uncertainties, blockers, and next action. Do not edit implementation code, mark packages done, approve high-risk changes or claims, or close the task. +""" + +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/packages/cli/src/templates/codex/agents/hermes-researcher.toml b/packages/cli/src/templates/codex/agents/hermes-researcher.toml new file mode 100644 index 0000000..ce4007c --- /dev/null +++ b/packages/cli/src/templates/codex/agents/hermes-researcher.toml @@ -0,0 +1,17 @@ +name = "hermes-researcher" +description = "Hermes researcher for literature, codebase, official documentation, and prior-art searches." +sandbox_mode = "workspace-write" + +developer_instructions = """ +You are the Hermes researcher. Do not spawn another sub-agent. + +Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: researcher:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. + +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. +""" + +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/packages/cli/src/templates/codex/agents/hermes-reviewer.toml b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml new file mode 100644 index 0000000..ea24928 --- /dev/null +++ b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml @@ -0,0 +1,17 @@ +name = "hermes-reviewer" +description = "Hermes independent reviewer for quality, evidence, claims, safety, closure, and statistics." +sandbox_mode = "read-only" + +developer_instructions = """ +You are the Hermes reviewer. Do not spawn another sub-agent. + +Native Codex dispatch is advisory. Require one validated blind-review dispatch body with `job_id`, bound revision, `role: reviewer:`, objective, and at most three refs. Do not read coder/runner explanations. For enforced execution, use `dispatch.py run --platform codex --mode strict`. + +Read only current artifacts, ledgers, criteria, and cited refs required by the active Profile. Return exactly the Result Envelope JSON. Evidence/claim profiles may propose a judgment but cannot approve facts, claims, or human records. +""" + +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/packages/cli/src/templates/codex/agents/hermes-runner.toml b/packages/cli/src/templates/codex/agents/hermes-runner.toml new file mode 100644 index 0000000..0710c4a --- /dev/null +++ b/packages/cli/src/templates/codex/agents/hermes-runner.toml @@ -0,0 +1,17 @@ +name = "hermes-runner" +description = "Hermes runner for experiments, tests, builds, and validation with reproducible records." +sandbox_mode = "workspace-write" + +developer_instructions = """ +You are the Hermes runner. Do not spawn another sub-agent. + +Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: runner:`, current work package, objective, and at most three refs. For enforced execution, use `dispatch.py run --platform codex --mode strict`. + +Run only authorized commands and register exact commands in run manifests, not in chat. Return exactly the Result Envelope JSON with existing `run_refs`; `evidence_refs` must be empty because runner success is not accepted evidence. +""" + +[features] +multi_agent = false + +[features.multi_agent_v2] +enabled = false diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index eb62357..e878787 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -25,11 +25,19 @@ 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_wait_timeout_ms: 120000 ms = 2 min. A worker that needs longer must +# report progress through its task record instead of holding the coordinator. [features.multi_agent_v2] enabled = true -max_concurrent_threads_per_session = 6 -min_wait_timeout_ms = 480000 +max_concurrent_threads_per_session = 3 +min_wait_timeout_ms = 120000 + +# Native custom-agent dispatch cannot mechanically replace or validate the +# agent prompt/output, so its Context Firewall authority is advisory only. +# Use the generated dispatch CLI strict wrapper for enforced Hermes work: +# python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +[hermes.context_firewall] +native_authority = "advisory" +strict_authority = "enforced" +strict_runtime = "codex exec --output-schema --json -o" diff --git a/packages/cli/src/templates/codex/hooks/session-start.py b/packages/cli/src/templates/codex/hooks/session-start.py index edb9e8a..24a721a 100644 --- a/packages/cli/src/templates/codex/hooks/session-start.py +++ b/packages/cli/src/templates/codex/hooks/session-start.py @@ -157,9 +157,9 @@ def _build_hermes_main_agent_boot_guard(trellis_dir: Path) -> str: Source: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Read it before governance-sensitive work; keep this SessionStart payload compact. Role: Main Agent / Main Pilot coordinates Hermes state, bounded subagent routing, record validation, handoff, and human/PI stop points. Hard limits: do not directly modify source, metrics, dataset splits, baselines, official evaluation, claim_allowed=true, or main merge unless Hermes state and human/root authority allow it. -Subagent context policy: minimal_file_context. Give role, task ID, allowed files, forbidden files, required output, stop condition, record schema, and claim boundary. Do not fork full chat by default. +Formal Codex context policy: native is advisory; use validated_dispatch_only through dispatch.py strict mode for enforced work. Keep raw output out of chat. Record policy: chat is not completion; completed, failed, blocked, stale, interrupted, or capacity-blocked work needs structured records. -Claim boundary: keep engineering success, runner success, evaluator approval, proxy evidence, scientific evidence, claim approval, and merge approval separate. +Claim boundary: keep engineering success, runner success, evidence review, proxy evidence, scientific evidence, claim approval, and merge approval separate. """ @@ -262,6 +262,20 @@ def _get_task_status(trellis_dir: Path, hook_input: dict) -> str: task_title = task_data.get("title", task_ref) task_status = task_data.get("status", "unknown") + if isinstance(task_data, dict) and any( + field in task_data for field in ("closure_state", "hermes_phase", "work_packages") + ): + scripts_dir = trellis_dir / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.closure import build_capsule, closure_next_action # type: ignore[import-not-found] + + capsule = build_capsule(task_dir, task_data, trellis_dir.parent) + return f"Status: HERMES CLOSURE\n{capsule}\nNext: {closure_next_action(task_data)}" + except Exception: + return f"Status: HERMES CLOSURE\nTask: {task_title}" + if task_status == "completed": return ( f"Status: COMPLETED\nTask: {task_title}\n" diff --git a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md index 3fb30f5..edb6dbf 100644 --- a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md +++ b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md @@ -9,27 +9,26 @@ Use this skill when a task asks for Hermes research discipline, evidence ledgers ## Read First -1. Resolve the active task from the prompt or `python3 ./.trellis/scripts/task.py current --source`. -2. Read `.trellis/hermes/config.yaml`. -3. Read `.trellis/hermes/state_machine.yaml`. -4. Read `.trellis/hermes/records/recordbus.md`. -5. Read `.trellis/hermes/records/subagent_protocol.md`. -6. Read the relevant role file from `.trellis/hermes/roles/`. -7. Read existing task records under `.trellis/tasks//hermes/`. -8. Read task artifacts such as `prd.md`, `design.md`, `implement.md`, and `check.jsonl` when present. +1. Resolve the active Hermes task. +2. Create a validated dispatch bound to the current `hermes_revision`. +3. Pass only its `job_id` to Claude Agent, or use Codex strict `dispatch.py run`. +4. Read at most three direct references named by the canonical dispatch. +5. Accept only the validated Result Envelope; raw traces never enter chat. ## Role Selection Choose the worker role from the task shape before dispatching: -- `scientist` for framing the question, evidence standard, and claim scope -- `coder` for source edits inside `allowed_files` -- `runner` for commands, tests, benchmarks, and measurements -- `evaluator` for result checks against a stated standard -- `reviewer` for diff and record review before handoff -- `literature` for papers, citations, and source trail work +- `planner` for research design, task planning, root-cause analysis, or method selection +- `researcher` for literature, codebase, official documentation, or prior art +- `coder` for implementation, tests, configuration, or repair inside `allowed_files` +- `runner` for experiments, tests, builds, or validation +- `reviewer` for quality, evidence, claims, safety, closure, or statistics -Do not dispatch a subagent without a task card. A result without a task card is +Set the matching `profile` in the task card. A profile is not a separate role +or agent template. + +Do not dispatch a subagent without a validated dispatch and task card. A result without them is not accepted, even if the text looks useful. ## Record Location @@ -56,10 +55,18 @@ Every task card should include `allowed_files`, `forbidden_files`, `record_uri`, and a clear `HumanGate` boundary. `worker_records.jsonl` is the acceptance trail for worker dispatch, not a chat-side summary. -The main agent should not receive long logs or long diffs in chat. Keep raw outputs in files and pass only structured summaries, evidence refs, risk flags, and decision requests. +The main agent should not receive long logs or long diffs in chat. Raw output +stays under `.trellis/.runtime/hermes-traces/`; pass only the sanitized +conclusion, uncertainties, refs, risks, and decision requests. Runtime helpers are available under `.trellis/scripts/hermes/`: +- `python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective ` +- `python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id ` +- `python3 ./.trellis/scripts/hermes/dispatch.py show --task --job-id --prompt` +- `python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict` +- `python3 ./.trellis/scripts/hermes/dispatch.py apply --task --job-id --result ` +- `python3 ./.trellis/scripts/hermes/dispatch.py list --task ` - `python3 ./.trellis/scripts/hermes/record.py append --task --record-type worker --json ''` - `python3 ./.trellis/scripts/hermes/validate.py --task --kind worker` - `python3 ./.trellis/scripts/hermes/record.py append --task --record-type plan_change --json ''` @@ -76,8 +83,8 @@ Use `.trellis/hermes/records/subagent_protocol.md` for long-running worker behav Long-running subagents must leave heartbeat and checkpoint records. If heartbeat records stop arriving, the main agent should mark the job stalled and resume from the latest checkpoint or dispatch a replacement worker. Maker and checker roles are separate. A checker reviews records, diffs, test output, and evidence; it does not replace human approval when HumanGate is required. -Reviewers and evaluators should get task-card context, evidence refs, and the -exact check to perform, not the coder or runner long chat. +Reviewers should get only the compact dispatch context and exact check to +perform, not the coder or runner long chat. ## Append Worker Records @@ -86,7 +93,7 @@ Append one JSON object per line to `worker_records.jsonl`. Task card: ```json -{"type":"task_card","id":"tc-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","job_id":"job-YYYYMMDD-HHMMSS-slug","role":"coder","worktree_id":"main","status":"queued","allowed_files":["path/**"],"forbidden_files":["path/**"],"heartbeat_interval":"5m","timeout_at":"YYYY-MM-DDTHH:MM:SSZ","checkpoint":"not-started","resume_from":"task_card","record_uri":".trellis/tasks//hermes/worker_records.jsonl","evidence_refs":[],"risk_flags":[]} +{"type":"task_card","id":"tc-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","job_id":"job-YYYYMMDD-HHMMSS-slug","role":"coder","profile":"implementation","worktree_id":"main","status":"queued","allowed_files":["path/**"],"forbidden_files":["path/**"],"heartbeat_interval":"5m","timeout_at":"YYYY-MM-DDTHH:MM:SSZ","checkpoint":"not-started","resume_from":"task_card","record_uri":".trellis/tasks//hermes/worker_records.jsonl","evidence_refs":[],"risk_flags":[]} ``` Heartbeat: diff --git a/packages/cli/src/templates/common/commands/start.md b/packages/cli/src/templates/common/commands/start.md index 606957a..1d3f66b 100644 --- a/packages/cli/src/templates/common/commands/start.md +++ b/packages/cli/src/templates/common/commands/start.md @@ -39,7 +39,7 @@ From Step 1 you know the current task and status. Check the task directory: For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task ` first. Use only the current package and its related references; load full PRD, reports, event history, ledgers, old tasks, and unrelated specs on demand. - **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. -- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Lightweight tasks can be PRD-only; complex tasks need `design.md` + `implement.md`. For the Claude Code path, keep requirement clarification in `prd.md`, continue repository-first follow-up questioning by entering `trellis-grill-me`, and treat it as a required planning gate. Before `trellis-grill-me` is complete, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. Before `task.py start`, record the decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, plus the task-level review-gate selection. Ask these in one `A.` / `B.` / `C.` style strategy block. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Complex tasks should keep that record in `implement.md` together with the enabled/disabled review gates and the preserved order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists for the selectable review gates (`trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, `trellis-merge-review`). If the user leaves all optional gates off, still record all five in the disabled list while keeping `trellis-check` fixed outside that set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. On Claude Code, that shared worktree is different from host `Agent(..., isolation: "worktree")`; when both collide, the hook removes the host isolation input before dispatch. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. +- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. For a Lean Research Closure task, read the Task Capsule first, then use `closure.py plan` and `closure.py validate`; `trellis-grill-me` is optional and only needed for an unresolved decision. Lightweight tasks can otherwise be PRD-only. For legacy or explicitly complex Claude Code tasks, keep requirement clarification in `prd.md` and use `trellis-grill-me` as the required planning gate before development strategy decisions or `task.py start`. Complex tasks need `design.md` + `implement.md`. Before `task.py start`, record the decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, plus the task-level review-gate selection. Ask these in one `A.` / `B.` / `C.` style strategy block. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Complex tasks should keep that record in `implement.md` together with the enabled/disabled review gates and the preserved order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists for the selectable review gates (`trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, `trellis-merge-review`). If the user leaves all optional gates off, still record all five in the disabled list while keeping `trellis-check` fixed outside that set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. On Claude Code, that shared worktree is different from host `Agent(..., isolation: "worktree")`; when both collide, the hook removes the host isolation input before dispatch. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. - **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: ```bash {{PYTHON_CMD}} ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform {{CLI_FLAG}} diff --git a/packages/cli/src/templates/common/skills/brainstorm.md b/packages/cli/src/templates/common/skills/brainstorm.md index 8dff091..7a49174 100644 --- a/packages/cli/src/templates/common/skills/brainstorm.md +++ b/packages/cli/src/templates/common/skills/brainstorm.md @@ -16,7 +16,11 @@ Do not ask the user to confirm facts that the repository can answer. Ask only fo --- -Use this skill during Phase 1 planning to turn the user's request into clear requirements and planning artifacts. +Use this skill only when Phase 1 analysis finds a real requirement or solution +decision that cannot be resolved from the repository. It is not the default +planning path: every task needs a short main-agent analysis and work-package +split, but clear deterministic work can proceed directly to `closure.py plan` +and `closure.py validate`. ## Preconditions @@ -47,10 +51,10 @@ Use a concise title from the user's request. Use a slug without a date prefix. ` 4. Ask the single highest-value remaining question. 5. Include your recommended answer with the question. 6. After each user answer, update `prd.md` before continuing. -7. Once repository-answerable questions are exhausted, enter `trellis-grill-me` and tighten the remaining requirement gaps one question at a time, each with a recommendation, while still updating `prd.md` after each answer. -8. On the Claude Code path, `trellis-grill-me` is a required planning gate. Before it is complete, do not switch into development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. +7. Do not enter `trellis-grill-me` only because a task is legacy or complex. Use it when a material solution, architecture, research-design, or scope decision remains unresolved after repository inspection, and tighten only that decision one question at a time with a recommendation. +8. For a Lean Research Closure task, always record the final intent, scope, definition of done, route, and work packages. When these are clear, run `closure.py plan` and `closure.py validate` directly. For an exploration route, keep the required grill focused on the changed research decision rather than turning it into a general interview. 9. Before implementation starts on the Claude Code path, record the development strategy decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, and the task-level review-gate selection. Ask these in a single `A.` / `B.` / `C.` style option block. New tasks must stamp that strategy block with `Review-gate contract: explicit-selection-v1`. Lightweight tasks may keep that record in `prd.md`; complex tasks should keep it in `implement.md` together with the enabled/disabled review gates and the preserved execution order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. The selectable review gates are `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user leaves all optional gates off, still record all five in the disabled list; `trellis-check` stays fixed outside this set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`, so do not record or accept the deep-review gate without that prerequisite. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. -10. For complex tasks, create or update `design.md` and `implement.md` only after `trellis-grill-me` is complete and implementation is about to start. +10. For complex tasks, create or update `design.md` and `implement.md` after the required clarification is complete. A Lean closure task needs them only when its scope actually requires them. 11. For a Hermes closure task, copy the final intent, scope boundaries, and acceptance criteria into `task.json` through `closure.py plan`. Keep 1-4 observable-result work packages by default; commands and file operations belong in `done_when`, not separate packages. Run `closure.py validate` before `task.py start`. Do not invent a project-specific product/spec hierarchy. If the repository already has product, domain, or spec docs, use them. If it does not, proceed with the evidence that exists. diff --git a/packages/cli/src/templates/markdown/index.ts b/packages/cli/src/templates/markdown/index.ts index 66a8e97..f47f4b7 100644 --- a/packages/cli/src/templates/markdown/index.ts +++ b/packages/cli/src/templates/markdown/index.ts @@ -95,3 +95,30 @@ export const guidesCrossLayerThinkingGuideContent: string = readLocalTemplate( export const guidesCodeReuseThinkingGuideContent: string = readLocalTemplate( "spec/guides/code-reuse-thinking-guide.md.txt", ); +export const guidesGeneralCodeGuidelinesContent: string = readLocalTemplate( + "spec/guides/general-code-guidelines.md.txt", +); + +// Language-specific writing guides. These are copied for every project but +// only the guide for the language being changed should enter task context. +export const languageGuidesIndexContent: string = readLocalTemplate( + "spec/languages/index.md.txt", +); +export const languageTypeScriptJavaScriptContent: string = readLocalTemplate( + "spec/languages/typescript-javascript.md.txt", +); +export const languagePythonContent: string = readLocalTemplate( + "spec/languages/python.md.txt", +); +export const languageGoContent: string = readLocalTemplate( + "spec/languages/go.md.txt", +); +export const languageRustContent: string = readLocalTemplate( + "spec/languages/rust.md.txt", +); +export const languageCppContent: string = readLocalTemplate( + "spec/languages/cpp.md.txt", +); +export const languageShellContent: string = readLocalTemplate( + "spec/languages/shell.md.txt", +); diff --git a/packages/cli/src/templates/markdown/spec/guides/general-code-guidelines.md.txt b/packages/cli/src/templates/markdown/spec/guides/general-code-guidelines.md.txt new file mode 100644 index 0000000..3d987f6 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/guides/general-code-guidelines.md.txt @@ -0,0 +1,89 @@ +# 通用代码规范 + +> **作用**:为科研项目中的代码修改提供最小、可执行的质量基线。它不替代项目现有规范,也不要求把所有代码改成同一种风格。 + +--- + +## 使用方式 + +本文档会随项目初始化生成,但为了保持工作代理的上下文最小化,不会自动加入每个任务。 + +- 涉及代码、脚本或配置修改,且没有更具体的项目规范时,主代理应把本文档加入实现或检查上下文; +- 已有后端、前端、包级或团队规范时,优先加入更具体的规范,按需再引用本文档; +- 当前任务还涉及特定语言时,从 `../languages/`(语言书写规范目录)只选择对应的一份规范; +- 文献检索、纯运行、纯报告等不修改代码的工作不需要加载本文档。 + +这使它成为代码任务的默认候选基线,而不是所有代理的隐式长上下文。 + +--- + +## 规则优先级 + +发生冲突时,按以下顺序处理: + +1. 项目现有的 `AGENTS.md`、`CONTRIBUTING.md`、格式化配置、检查配置和真实代码模式; +2. 当前任务的范围、研究方案、项目约束和完成条件; +3. 当前语言或框架已经选定的公开规范与工具配置; +4. 本文档的通用基线。 + +不要以“符合通用规范”为理由覆盖项目已有约定,也不要进行与任务无关的大面积格式化或重构。 + +## 修改前 + +- 先读取当前任务的目标、范围、完成条件和相关规范; +- 搜索已有实现,至少查看一个相近示例; +- 明确输入、输出、错误路径和验证方式; +- 科研改动涉及数据集、数据切分、模型结构、损失函数、指标、基线或主张范围时,先按任务流程记录计划变更; +- 不确定需求、方案或研究假设时,先讨论并记录决定,不用代码猜测答案。 + +## 实现规则 + +- 名称表达业务或研究含义,避免无语义缩写和隐式全局状态; +- 一个模块或函数只承担清晰、可说明的职责;出现重复模式时优先复用现有实现,不为一次性代码过早抽象; +- 在外部边界处理类型、格式和错误:文件、网络响应、命令输出、配置和未类型化数据都不能直接假定正确; +- 配置、常量和路径集中管理;不要散落重复值、机器绝对路径或环境专属开关; +- 错误信息说明失败的操作和可处理原因,不泄露密钥、私有数据、完整输入或内部路径; +- 只修改当前任务允许的文件。新增依赖、配置和接口时,同时说明用途、兼容性影响和验证方式。 + +## 科研代码与可复现性 + +- 把实验输入、参数、随机种子、环境版本、运行命令和产物位置记录在可追踪文件中; +- 数据读取、预处理、训练、评估和报告应保留可复跑入口,避免依赖人工临时步骤; +- 指标应明确计算对象、数据切分、聚合方式和比较基线; +- 结果文件应能关联回对应代码版本、配置和运行记录; +- 单次运行只是运行结果,不自动等同于证据充分、任务关闭或科研主张成立。 + +## 测试与验证 + +- 测试可观察行为和失败边界,不只测试实现细节; +- 修复缺陷时,优先增加能复现该缺陷的回归测试; +- 运行项目已有的格式化、静态检查、类型检查、构建和目标测试;没有现成命令时,只补与当前改动直接相关的最小验证; +- 在任务记录中保存实际运行的验证命令、结果和必要产物引用; +- 审查时检查范围漂移、错误处理、测试覆盖、可复现性和研究结论边界。 + +## 安全与协作 + +- 密码、访问令牌、私有数据和 `.env` 文件只通过安全的运行环境提供,绝不写入源码、测试、日志、任务记录或提交; +- 外部输入按不可信数据处理;涉及删除、覆盖、网络上传、发布和付费资源时先确认边界; +- 遵从项目的提交、版本、许可证和贡献规范;没有项目约定时,提交应保持小而聚焦,并说明行为变化与验证结果; +- 代码注释解释不明显的设计原因、研究限制或反直觉行为,不重复代码字面意思。 + +## 完成前检查 + +- [ ] 修改符合当前项目的真实代码模式和任务范围; +- [ ] 没有秘密信息、绝对个人路径或未声明的环境依赖; +- [ ] 已运行适用的格式化、检查、类型检查、构建或测试; +- [ ] 科研代码的配置、种子、输入和产物可以追溯; +- [ ] 验证结果和不确定项已写入任务记录; +- [ ] 没有把单次运行或聊天声明当成任务完成或科研结论。 + +## 公开基线 + +本文档参考以下公开资料的共同原则;具体语言规范应由项目明确选择后再写入语言或模块规范: + +- `EditorConfig`(跨编辑器格式规则):https://editorconfig.org/ +- `OWASP Secure Coding Practices`(安全编码实践):https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/ +- `Best Practices for Scientific Computing`(科研计算最佳实践):https://doi.org/10.1371/journal.pbio.1001745 +- `Good enough practices in scientific computing`(科研计算最低充分实践):https://doi.org/10.1371/journal.pcbi.1005510 + +对 TypeScript、Python、Go、Rust、Java、C#、C++、Kotlin 或 Swift 项目,应在对应规范中记录采用的官方风格指南、格式化工具和静态检查工具,而不是在这里混合多套互相冲突的细则。 diff --git a/packages/cli/src/templates/markdown/spec/guides/index.md.txt b/packages/cli/src/templates/markdown/spec/guides/index.md.txt index a887595..47b81a3 100644 --- a/packages/cli/src/templates/markdown/spec/guides/index.md.txt +++ b/packages/cli/src/templates/markdown/spec/guides/index.md.txt @@ -21,6 +21,7 @@ | 指南 | 作用 | 适用场景 | |------|------|----------| +| [通用代码规范](./general-code-guidelines.md) | 提供质量、安全、测试和科研可复现基线 | 修改代码、配置、脚本或实验入口的任务 | | [代码复用思考指南](./code-reuse-thinking-guide.md) | 识别重复模式,减少重复实现 | 当你注意到相似模式反复出现时 | | [跨层思考指南](./cross-layer-thinking-guide.md) | 梳理多层之间的数据流与职责边界 | 涉及多个层级的功能开发 | @@ -28,6 +29,14 @@ ## 快速参考:什么时候该主动思考 +### 什么时候该检查通用代码规范 + +- [ ] 任务要新增或修改代码、脚本、配置或实验入口 +- [ ] 任务涉及外部输入、数据、路径、依赖、密钥或运行环境 +- [ ] 任务需要登记测试、实验、构建或可复现证据 + +→ 阅读 [通用代码规范](./general-code-guidelines.md) + ### 什么时候该检查跨层问题 - [ ] 功能会同时触及 3 层及以上(API、Service、Component、Database) diff --git a/packages/cli/src/templates/markdown/spec/languages/cpp.md.txt b/packages/cli/src/templates/markdown/spec/languages/cpp.md.txt new file mode 100644 index 0000000..249ca70 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/cpp.md.txt @@ -0,0 +1,23 @@ +# C++ 书写规范 + +## 默认规则 + +- 优先使用仓库的 `clang-format`(C++ 格式化)、`clang-tidy`(C++ 静态检查)、编译器警告和构建配置;没有项目约定时参考 C++ 核心规范; +- 用 RAII(资源获取即初始化)管理文件、锁、内存和设备资源;避免裸 `new`(直接分配)与 `delete`(直接释放),明确拥有者与非拥有者; +- 默认使用值、引用、`const`(只读)和标准容器。传递视图或指针时必须说明生命周期、可空性和边界; +- 所有数组、缓冲区、索引、转换和外部数据入口都要有边界与溢出检查; +- 异常、错误码和断言必须遵从项目现有错误模型,不能在同一模块混用不兼容的失败语义; +- 性能优化先保留基准与测量条件;不要以牺牲资源安全、可读性或可复现性换取未经验证的微小优化。 + +## 测试与检查 + +- 运行项目已有的构建、单元测试、格式化和静态检查; +- 修改资源管理、并发、数值边界或原生接口时,覆盖失败、边界和生命周期路径; +- 科研数值代码还应记录编译器、优化选项、硬件差异和容差标准。 + +## 公开基线 + +- `C++ Core Guidelines`(C++ 核心规范):https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines +- `Google C++ Style Guide`(谷歌 C++ 规范):https://google.github.io/styleguide/cppguide.html +- `ClangFormat`(C++ 格式化):https://clang.llvm.org/docs/ClangFormat.html +- `Clang-Tidy`(C++ 静态检查):https://clang.llvm.org/extra/clang-tidy/ diff --git a/packages/cli/src/templates/markdown/spec/languages/go.md.txt b/packages/cli/src/templates/markdown/spec/languages/go.md.txt new file mode 100644 index 0000000..7b44036 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/go.md.txt @@ -0,0 +1,22 @@ +# Go 书写规范 + +## 默认规则 + +- 使用项目已有的 `gofmt`(Go 格式化)和检查配置;提交前保持受影响文件经过 `gofmt`(Go 格式化); +- 包名简短、小写且表达领域,导出标识使用可读名称。不要在包名中重复目录含义; +- 错误必须向调用方返回或在边界处处理;包装错误时保留原始原因,便于使用 `errors.Is`(错误匹配)和 `errors.As`(错误提取); +- 把 `context.Context`(请求上下文)作为需要取消、截止时间或请求范围的方法第一个参数,不把它存进长期结构体; +- 让零值可用,明确资源所有权。打开文件、连接或锁后,确认关闭与释放路径; +- 并发代码要说明所有权、退出条件和错误传播。不要通过共享可变状态或无退出的 goroutine(协程)隐藏工作。 + +## 测试与检查 + +- 使用项目已有的 `go test`(Go 测试)、`go vet`(Go 静态检查)和其他检查命令; +- 表驱动测试适合多个等价输入,但测试名称仍应说明行为; +- 修改并发、超时或资源释放时,补充失败、取消或泄漏路径验证。 + +## 公开基线 + +- `Go Code Review Comments`(Go 代码审查规范):https://go.dev/wiki/CodeReviewComments +- `gofmt`(Go 格式化说明):https://go.dev/blog/gofmt +- `go test`(Go 测试命令):https://pkg.go.dev/cmd/go#hdr-Test_packages diff --git a/packages/cli/src/templates/markdown/spec/languages/index.md.txt b/packages/cli/src/templates/markdown/spec/languages/index.md.txt new file mode 100644 index 0000000..be55168 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/index.md.txt @@ -0,0 +1,21 @@ +# 语言书写规范 + +> **作用**:按实际修改的语言选择一份最小规范,与通用代码规范一起使用。 + +## 选择规则 + +- 先读取项目已有的语言配置、格式化配置、静态检查配置和真实代码; +- 只把当前修改涉及的语言规范加入任务上下文,不要一次加载全部文件; +- 同一任务包含多种语言时,分别引用对应规范;公共要求仍以 `../guides/general-code-guidelines.md`(通用代码规范)为准; +- 工具只是建议基线。项目未配置某项工具时,不要擅自引入它或进行大面积格式化。 + +| 文件 | 适用场景 | +| --- | --- | +| `typescript-javascript.md` | TypeScript、JavaScript、Node.js 工具和前端代码 | +| `python.md` | 数据处理、训练、评估、自动化与科研脚本 | +| `go.md` | Go 服务、命令行工具和并发程序 | +| `rust.md` | Rust 工具、库和可靠性要求较高的组件 | +| `cpp.md` | C++ 性能模块、原生扩展和数值计算代码 | +| `shell.md` | Bash 脚本、实验编排和本地自动化 | + +语言规范提供默认书写规则,不改变研究方案、数据边界、指标定义或任务关闭条件。 diff --git a/packages/cli/src/templates/markdown/spec/languages/python.md.txt b/packages/cli/src/templates/markdown/spec/languages/python.md.txt new file mode 100644 index 0000000..08f7d24 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/python.md.txt @@ -0,0 +1,24 @@ +# Python 书写规范 + +## 默认规则 + +- 遵从项目已有的 `pyproject.toml`(Python 项目配置)、格式化和检查配置;没有项目约定时,以 `PEP 8`(Python 代码风格)和 `PEP 257`(文档字符串规范)为基线; +- 为公开函数、复杂数据结构和外部边界补充类型标注;不要为了消除类型错误滥用 `Any`(任意类型)或忽略检查; +- 使用 `pathlib`(路径库)处理文件路径,使用显式配置传入数据路径、输出路径和参数;不要硬编码个人目录或隐式依赖当前工作目录; +- 异常只捕获能够处理的类型,并保留上下文。禁止空的 `except`(异常捕获)和无说明的宽泛吞错; +- 函数保持单一、可测试的职责。可变默认参数、隐藏全局状态和导入时执行昂贵计算应避免; +- 公开模块和研究关键步骤说明输入、输出、单位、随机性和副作用。 + +## 科研与测试 + +- 训练、评估和数据处理入口应接收显式配置,并记录随机种子、版本和产物位置; +- 优先运行项目已有的 `Ruff`(Python 检查与格式化)、`Black`(格式化)、类型检查和 `pytest`(测试框架)命令;未配置时不要强制新增工具; +- 测试避免依赖本机路径、网络和随机顺序;需要随机性时固定或记录种子。 + +## 公开基线 + +- `PEP 8`(Python 代码风格):https://peps.python.org/pep-0008/ +- `PEP 257`(文档字符串规范):https://peps.python.org/pep-0257/ +- `Ruff`(Python 检查与格式化):https://docs.astral.sh/ruff/ +- `Black`(Python 格式化风格):https://black.readthedocs.io/en/stable/the_black_code_style/ +- `pytest Good Integration Practices`(pytest 集成实践):https://docs.pytest.org/en/stable/explanation/goodpractices.html diff --git a/packages/cli/src/templates/markdown/spec/languages/rust.md.txt b/packages/cli/src/templates/markdown/spec/languages/rust.md.txt new file mode 100644 index 0000000..46462e4 --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/rust.md.txt @@ -0,0 +1,23 @@ +# Rust 书写规范 + +## 默认规则 + +- 使用项目已有的 `rustfmt`(Rust 格式化)和 `Clippy`(Rust 静态检查)配置;不要为了通过检查而全局关闭 lint(静态规则); +- 让所有权、借用和生命周期表达真实资源关系。不要为了图省事无条件 `clone`(复制)或把短生命周期引用伪装成长生命周期; +- 对可能失败的文件、网络、解析和外部命令操作返回 `Result`(成功或错误结果),在边界补充可诊断的错误上下文; +- `unwrap`(强制取值)和 `expect`(带说明的强制取值)只用于测试或已被明确证明的不变量,不能替代错误处理; +- `unsafe`(不安全代码)必须范围最小、说明所依赖的不变量,并有针对性测试; +- 公共类型和函数说明所有权、错误、并发与副作用语义,避免暴露无意义的内部表示。 + +## 测试与检查 + +- 优先运行项目已有的 `cargo fmt --check`(格式检查)、`cargo clippy`(静态检查)和 `cargo test`(测试); +- 对解析、序列化、资源管理和并发边界补充失败路径测试; +- 性能优化应保留基准、输入和比较条件,不能只凭单次运行判断。 + +## 公开基线 + +- `Rust API Guidelines`(Rust 接口规范):https://rust-lang.github.io/api-guidelines/ +- `Rust Style Guide`(Rust 书写规范):https://doc.rust-lang.org/stable/style-guide/ +- `Rustfmt`(Rust 格式化):https://rust-lang.github.io/rustfmt/ +- `Clippy`(Rust 静态检查):https://doc.rust-lang.org/stable/clippy/ diff --git a/packages/cli/src/templates/markdown/spec/languages/shell.md.txt b/packages/cli/src/templates/markdown/spec/languages/shell.md.txt new file mode 100644 index 0000000..838416b --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/shell.md.txt @@ -0,0 +1,22 @@ +# Shell / Bash 书写规范 + +## 默认规则 + +- 先声明解释器。使用 Bash 特性时写 `#!/usr/bin/env bash`(Bash 解释器);需要兼容 `sh`(标准 Shell)时不要混入 Bash 数组、`[[ ... ]]`(Bash 条件)等扩展; +- 变量、路径和命令替换默认加引号;参数列表使用数组,避免依赖空格分割或 `eval`(二次解释命令); +- 对失败语义有明确预期时使用 `set -euo pipefail`(严格错误处理),但先理解条件判断、管道和允许失败命令的行为; +- 临时文件使用 `mktemp`(安全临时文件),并用 `trap`(退出清理)处理清理和中断;不要使用固定临时路径或不受限的递归删除; +- 外部命令、下载内容和环境变量都按不可信输入处理;验证参数、检查退出码,避免把数据拼接进可执行命令; +- 脚本应接受显式输入、输出和配置路径,记录实际执行命令;不要依赖个人目录、交互式提示或未声明的环境状态。 + +## 测试与检查 + +- 项目已配置或当前环境确认可用时,运行 `ShellCheck`(Shell 静态检查)和已有的脚本测试;未配置时只做与改动直接相关的最小脚本验证; +- 对包含路径空格、缺失变量、失败命令和中断清理的情况做最小验证; +- 运行实验脚本前确认输出目录、覆盖策略、数据权限和资源消耗。 + +## 公开基线 + +- `Google Shell Style Guide`(谷歌 Shell 规范):https://google.github.io/styleguide/shellguide.html +- `Bash Reference Manual`(Bash 参考手册):https://www.gnu.org/software/bash/manual/bash.html +- `ShellCheck`(Shell 静态检查):https://www.shellcheck.net/ diff --git a/packages/cli/src/templates/markdown/spec/languages/typescript-javascript.md.txt b/packages/cli/src/templates/markdown/spec/languages/typescript-javascript.md.txt new file mode 100644 index 0000000..a364ffc --- /dev/null +++ b/packages/cli/src/templates/markdown/spec/languages/typescript-javascript.md.txt @@ -0,0 +1,24 @@ +# TypeScript / JavaScript 书写规范 + +## 默认规则 + +- 优先使用项目已有的 `tsconfig`(TypeScript 配置)、`ESLint`(静态检查)和 `Prettier`(格式化)配置;没有项目约定时,新增 TypeScript 代码优先保持 `strict`(严格类型)兼容; +- 对外部数据、JSON、环境变量和命令输出先按 `unknown`(未知类型)处理,再校验并转换;不要用 `any`(任意类型)绕过边界问题; +- 类型、常量和错误处理应靠近其领域所有者。跨模块复用的类型放在稳定入口,避免每个调用点重复断言; +- 函数、文件和导出名称表达行为或领域含义。布尔值使用可读的肯定或疑问式名称; +- 异步操作必须处理失败路径、取消或超时语义;不要悄悄吞掉 `Promise`(异步结果)拒绝; +- 除非已有约定,不进行全仓库格式化、导入重排或从 JavaScript 批量迁移到 TypeScript。 + +## 测试与检查 + +- 改动公开接口、解析逻辑或运行时分支时,覆盖成功、无效输入和失败路径; +- 优先运行项目已有的格式化、`ESLint`(静态检查)、`tsc --noEmit`(类型检查)和目标测试; +- 类型断言只能缩小已经验证过的值,不能替代测试或运行时校验。 + +## 公开基线 + +- `Google TypeScript Style Guide`(谷歌 TypeScript 规范):https://google.github.io/styleguide/tsguide.html +- `Google JavaScript Style Guide`(谷歌 JavaScript 规范):https://google.github.io/styleguide/jsguide.html +- `typescript-eslint`(TypeScript 静态检查规则):https://typescript-eslint.io/ +- `ESLint`(JavaScript 静态检查):https://eslint.org/docs/latest/use/core-concepts/ +- `Prettier`(代码格式化):https://prettier.io/docs/ 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 a1d3778..80a3eff 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -12,12 +12,14 @@ import shlex import subprocess import sys +from fnmatch import fnmatchcase from pathlib import Path from typing import Any MUTATION_TOOL_NAMES = {"Edit", "Write", "MultiEdit", "apply_patch"} WRITE_TOOL_NAMES = {*MUTATION_TOOL_NAMES, "Bash"} +READ_TOOL_NAMES = {"Read", "Glob", "Grep"} AGENT_IDENTITY_KEYS = { "agent_role", "agent_name", @@ -30,14 +32,19 @@ } MAIN_AGENT_IDENTITIES = {"main", "coordinator", "main_agent"} SUBAGENT_IDENTITIES = { + "planner", "builder", "coder", "runner", "reviewer", "researcher", + "research_scout", "analyst", "literature", "evaluator", + "scientist", + "claim_reviewer", + "evidence_curator", "trellis_implement", "trellis_check", "trellis_research", @@ -47,6 +54,8 @@ "trellis_merge_review", "trellis_improve_codebase_architecture", "hermes_coder", + "hermes_planner", + "hermes_researcher", "hermes_runner", "hermes_reviewer", "hermes_literature", @@ -235,6 +244,38 @@ def context_payload(event_name: str, context: str) -> dict[str, Any]: } +def updated_tool_output_payload( + summary: dict[str, Any], + tool_response: Any, + *, + reason: str | None = None, +) -> dict[str, Any]: + if not isinstance(tool_response, dict): + return block_payload( + "PostToolUse", + "Hermes Context Firewall: Agent output shape is unsupported; raw output was not accepted.", + ) + replacement = dict(tool_response) + replacement["content"] = [ + { + "type": "text", + "text": json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + } + ] + output: dict[str, Any] = { + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "updatedToolOutput": replacement, + } + } + if reason: + output["decision"] = "block" + output["reason"] = reason + return { + **output, + } + + def validate_worker_records( root: Path, runtime_root: Path, @@ -439,7 +480,13 @@ def bash_command_from_tool_input(value: Any) -> str | None: def normalized_agent_identity(value: str) -> str: - return value.strip().lower().replace("-", "_").replace(" ", "_") + return ( + value.strip() + .lower() + .replace("-", "_") + .replace("/", "_") + .replace(" ", "_") + ) def direct_agent_identity_values(value: Any) -> list[str]: @@ -682,6 +729,40 @@ def is_readonly_record_jq(tokens: list[str]) -> bool: ) +def is_main_control_bash(command: str) -> bool: + tokens = shell_tokens(command) + if not tokens or has_shell_control(command, tokens): + return False + index = 0 + executable = command_basename(tokens[index]).casefold() + if executable not in {"python", "python3", "py"}: + return False + index += 1 + if executable == "py" and index < len(tokens) and tokens[index] == "-3": + index += 1 + if index + 1 < len(tokens) and tokens[index] == "-X": + index += 2 + if index >= len(tokens): + return False + script = tokens[index].replace("\\", "/") + while script.startswith("./"): + script = script[2:] + index += 1 + if index >= len(tokens): + return False + allowed: dict[str, set[str]] = { + ".trellis/scripts/hermes/dispatch.py": { + "create", "validate", "show", "run", "apply", "list", "status", "schema", "supersede", + }, + ".trellis/scripts/closure.py": { + "status", "next", "capsule", "validate", "package-start", "package-check", + "package-done", "package-block", "audit", "repair", "amend", "handoff", "close", + }, + ".trellis/scripts/task.py": {"current", "archive", "finish"}, + } + return tokens[index] in allowed.get(script, set()) + + def is_main_readonly_bash(command: str) -> bool: tokens = shell_tokens(command) if not tokens or has_shell_control(command, tokens): @@ -690,6 +771,7 @@ def is_main_readonly_bash(command: str) -> bool: is_readonly_git_command(tokens) or is_readonly_record_cat(tokens) or is_readonly_record_jq(tokens) + or is_main_control_bash(command) ) @@ -713,14 +795,15 @@ def main_agent_bash_denial(command: str) -> str: next_step = ( "dispatch a runner subagent" if is_runner_bash_work(command) - else "dispatch the appropriate researcher or analyst subagent" + else "dispatch the appropriate researcher or planner subagent" ) return ( "Hermes Runtime: main agent firewall denied Bash. Main agent is " "coordinator only and may run only read-only routing commands: " "git status --short -- , git diff --name-only/--stat -- , " "git log --oneline/--stat -- , " - "or cat/jq against RecordBus JSONL. " + "cat/jq against RecordBus JSONL, or parameterized Hermes " + "dispatch/closure/task control-plane commands. " f"Next step: {next_step} with a task_card." ) @@ -1000,14 +1083,33 @@ def add_changed_file(files: list[str], raw_path: str) -> None: files.append(normalized) -def task_cards_by_job(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: +def normalize_task_card_for_gate( + root: Path, + card: dict[str, Any], +) -> dict[str, Any]: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.roles import normalize_task_card # type: ignore[import-not-found] + + normalized, _warnings = normalize_task_card(card) + return normalized + except (ImportError, ValueError): + return card + + +def task_cards_by_job( + records: list[dict[str, Any]], + root: Path, +) -> dict[str, dict[str, Any]]: cards: dict[str, dict[str, Any]] = {} for record in records: if record.get("type") != "task_card": continue job_id = record.get("job_id") if isinstance(job_id, str) and job_id not in cards: - cards[job_id] = record + cards[job_id] = normalize_task_card_for_gate(root, record) return cards @@ -1042,6 +1144,7 @@ def related_records( coder_job_id: str, role: str, record_types: set[str], + profiles: set[str] | None = None, ) -> list[dict[str, Any]]: matches: list[dict[str, Any]] = [] for record in records: @@ -1053,6 +1156,8 @@ def related_records( card = cards.get(job_id) if card is None or card.get("role") != role: continue + if profiles is not None and card.get("profile") not in profiles: + continue if ( job_id == coder_job_id or card.get("parent_job_id") == coder_job_id @@ -1062,8 +1167,11 @@ def related_records( return matches -def completed_coder_results(records: list[dict[str, Any]]) -> list[dict[str, Any]]: - cards = task_cards_by_job(records) +def completed_coder_results( + records: list[dict[str, Any]], + root: Path, +) -> list[dict[str, Any]]: + cards = task_cards_by_job(records, root) results: list[dict[str, Any]] = [] for record in records: if record.get("type") != "result": @@ -1100,13 +1208,15 @@ def runner_result_has_passing_test( runner_result: dict[str, Any], run_manifests: dict[str, dict[str, Any]], ) -> bool: - evidence_refs = runner_result.get("evidence_refs") - if not isinstance(evidence_refs, list): + run_refs = runner_result.get("run_refs") + if not isinstance(run_refs, list): + run_refs = runner_result.get("evidence_refs") + if not isinstance(run_refs, list): return False - for evidence_ref in evidence_refs: - if not isinstance(evidence_ref, str): + for run_ref in run_refs: + if not isinstance(run_ref, str): continue - manifest = run_manifests.get(evidence_ref) + manifest = run_manifests.get(run_ref) if manifest is not None and manifest.get("exit_code") == 0: return True return False @@ -1118,7 +1228,7 @@ def stop_completion_reason( task: str, records: list[dict[str, Any]], ) -> str | None: - coder_results = completed_coder_results(records) + coder_results = completed_coder_results(records, root) if not coder_results: return ( "Hermes Runtime: Stop requires a completed coder result in " @@ -1150,13 +1260,20 @@ def stop_completion_reason( "Hermes logs locally." ) - cards = task_cards_by_job(records) + cards = task_cards_by_job(records, root) run_manifests = read_run_manifest_index(root, task) for result in matching_results: job_id = record_job_id(result) if job_id is None: continue - runner_results = related_records(records, cards, job_id, "runner", {"result"}) + runner_results = related_records( + records, + cards, + job_id, + "runner", + {"result"}, + {"test", "build", "validation"}, + ) has_passing_test = any( runner_result_has_passing_test(runner_result, run_manifests) for runner_result in runner_results @@ -1173,6 +1290,7 @@ def stop_completion_reason( job_id, "reviewer", {"checkpoint", "result"}, + {"quality", "safety"}, ) if not review_records: return ( @@ -1196,6 +1314,25 @@ def guard_tool_input_permissions( command = bash_command_from_tool_input(tool_input) if command is None: return "Hermes Runtime: cannot safely parse Bash tool_input; denying by default." + agent_id = data.get("agent_id") + if isinstance(agent_id, str) and agent_id: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import load_dispatch_for_agent # type: ignore[import-not-found] + + bound_dispatch = load_dispatch_for_agent( + root / ".trellis" / "tasks" / task, + agent_id, + ) + except Exception: + return "Hermes Runtime: Bash caller has no valid dispatch binding." + role = str(bound_dispatch.get("role") or "") + if role in {"planner", "researcher"}: + return f"Hermes Runtime: {role} dispatch cannot execute Bash." + if role == "reviewer" and not is_readonly_git_command(shell_tokens(command) or []): + return "Hermes Runtime: reviewer Bash is limited to scoped read-only git inspection." target_files, parse_error = extract_bash_targets(root, command) if parse_error is not None: return f"Hermes Runtime: {parse_error}; denying by default." @@ -1207,6 +1344,9 @@ def guard_tool_input_permissions( target_files = collect_tool_target_files(root, tool_name, tool_input) if not target_files: return None + for target in target_files: + if _target_crosses_symlink(root, target): + return f"Hermes Runtime: write target crosses a symlink or repository boundary: {target}" records = read_worker_records(worker_records) job_id = extract_job_id_from_tool_input(tool_input) @@ -1236,6 +1376,373 @@ def guard_tool_input_permissions( return None +def _target_crosses_symlink(root: Path, target: str) -> bool: + normalized = normalize_target_path(root, target) + if normalized is None or normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized): + return True + current = root.absolute() + for part in Path(normalized).parts: + current = current / part + try: + if current.is_symlink(): + return True + except OSError: + return True + return False + + +def _read_target_allowed(target: str, refs: list[str], patterns: list[str]) -> bool: + normalized = target.replace("\\", "/").removeprefix("./").rstrip("/") + if normalized in refs: + return True + if any(ref.startswith(normalized + "/") for ref in refs): + return True + return any( + fnmatchcase(normalized, pattern) + or Path(normalized).match(pattern) + or pattern.startswith(normalized + "/") + for pattern in patterns + ) + + +def guard_agent_read_permissions( + root: Path, + task: str, + data: dict[str, Any], +) -> str | None: + agent_id = data.get("agent_id") + agent_type = data.get("agent_type") + if not isinstance(agent_id, str) or not agent_id: + return "Hermes Context Firewall: subagent read has no bound agent_id." + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import ( # type: ignore[import-not-found] + DispatchError, + load_dispatch_for_agent, + validate_agent_binding, + ) + except ImportError: + return "Hermes Context Firewall: dispatch read guard is unavailable." + task_dir = root / ".trellis" / "tasks" / task + try: + dispatch = load_dispatch_for_agent(task_dir, agent_id) + validate_agent_binding( + dispatch, + agent_id=agent_id, + agent_type=str(agent_type or "") or None, + session_id=str(data.get("session_id") or "") or None, + ) + except DispatchError as exc: + return f"Hermes Context Firewall: {exc.code}." + targets = collect_tool_target_files( + root, + str(data.get("tool_name") or ""), + data.get("tool_input", {}), + ) + if not targets: + return "Hermes Context Firewall: read/search target is not explicit." + refs = [str(item) for item in dispatch.get("refs") or [] if isinstance(item, str)] + patterns = [ + str(item) + for item in dispatch.get("allowed_files") or [] + if isinstance(item, str) + ] + for target in targets: + if _target_crosses_symlink(root, target): + return f"Hermes Context Firewall: read target crosses a symlink or repository boundary: {target}" + lowered = target.casefold() + if dispatch.get("blind_review") and any( + marker in lowered + for marker in ( + "worker_records.jsonl", + ".result.json", + ".raw.jsonl", + "hermes-traces", + "handoff.md", + ) + ): + return "Hermes Context Firewall: blind reviewer cannot read worker explanations or raw traces." + if not _read_target_allowed(target, refs, patterns): + return f"Hermes Context Firewall: read target is outside dispatch refs/allowed_files: {target}" + return None + + +def read_active_task_data(root: Path, task: str | None) -> dict[str, Any]: + if not task: + return {} + path = root / ".trellis" / "tasks" / task / "task.json" + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def is_hermes_closure_task(task_data: dict[str, Any]) -> bool: + return bool(task_data) and any( + key in task_data for key in ("closure_state", "hermes_phase", "work_packages") + ) + + +def record_hook_heartbeat( + root: Path, + platform: str | None, + *, + task_id: str | None = None, + session_id: str | None = None, +) -> None: + if platform not in {"claude", "codex"}: + return + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.firewall import record_firewall_heartbeat # type: ignore[import-not-found] + + record_firewall_heartbeat( + root, + platform, + "hooks", + task_id=task_id, + session_id=session_id, + ) + except Exception: + pass + + +def _agent_result_text(value: Any) -> str | None: + if isinstance(value, str): + return value.strip() or None + if isinstance(value, list): + parts = [_agent_result_text(item) for item in value] + text = "\n".join(part for part in parts if part) + return text or None + if not isinstance(value, dict): + return None + if value.get("job_id") and value.get("conclusion"): + return json.dumps(value, ensure_ascii=False) + for key in ("text", "output", "result", "content", "message", "response"): + if key in value: + text = _agent_result_text(value.get(key)) + if text: + return text + return None + + +def extract_agent_result(data: dict[str, Any]) -> str | None: + for key in ( + "last_assistant_message", + "tool_response", + "tool_output", + "updated_tool_output", + "response", + "result", + "output", + ): + text = _agent_result_text(data.get(key)) + if text: + return text + transcript = data.get("transcript_path") + if isinstance(transcript, str) and transcript: + try: + path = Path(transcript).resolve() + raw = path.read_text(encoding="utf-8", errors="replace")[-262144:] + except OSError: + return None + for line in reversed(raw.splitlines()): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + text = _agent_result_text(value) + if text and "job_id" in text: + return text + return None + + +def _job_id_from_result(raw_result: str | None) -> str | None: + if not raw_result: + return None + try: + value = json.loads(raw_result) + except json.JSONDecodeError: + match = re.search(r"(?im)^\s*job_id\s*[:=]\s*([A-Za-z0-9][A-Za-z0-9._-]{0,79})\s*$", raw_result) + return match.group(1) if match else None + if isinstance(value, dict): + job_id = value.get("job_id") + if isinstance(job_id, str): + return job_id + return None + + +def agent_event_identity( + data: dict[str, Any], + event_name: str, +) -> tuple[str | None, str | None]: + if event_name == "PostToolUse": + tool_input = data.get("tool_input") + tool_response = data.get("tool_response") + agent_type = tool_input.get("subagent_type") if isinstance(tool_input, dict) else None + agent_id = tool_response.get("agentId") if isinstance(tool_response, dict) else None + return ( + str(agent_type) if isinstance(agent_type, str) else None, + str(agent_id) if isinstance(agent_id, str) else None, + ) + agent_type = data.get("agent_type") + agent_id = data.get("agent_id") + return ( + str(agent_type) if isinstance(agent_type, str) else None, + str(agent_id) if isinstance(agent_id, str) else None, + ) + + +def handle_agent_result_event( + root: Path, + task: str, + task_data: dict[str, Any], + data: dict[str, Any], + event_name: str, +) -> dict[str, Any] | None: + task_dir = root / ".trellis" / "tasks" / task + dispatches = task_dir / "hermes" / "dispatches" + if not dispatches.is_dir(): + return None + raw_result = extract_agent_result(data) + agent_type, agent_id = agent_event_identity(data, event_name) + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import ( # type: ignore[import-not-found] + DispatchError, + accept_result_text, + load_dispatch, + load_dispatch_for_agent, + load_sanitized_result, + role_for_agent_type, + sanitized_summary, + validate_agent_binding, + ) + except ImportError: + return block_payload(event_name, "Hermes Context Firewall runtime is unavailable.") + + if role_for_agent_type(agent_type) is None: + return None + if not agent_id: + return block_payload(event_name, "Hermes Context Firewall: Hermes Agent result has no agent_id binding.") + try: + initial_state = load_dispatch_for_agent(task_dir, agent_id) + validate_agent_binding( + initial_state, + agent_id=agent_id, + agent_type=agent_type, + session_id=str(data.get("session_id") or "") or None, + ) + except DispatchError as exc: + return block_payload(event_name, f"Hermes Context Firewall: {exc.code}.") + job_id = str(initial_state["job_id"]) + result_job_id = _job_id_from_result(raw_result) + if result_job_id is not None and result_job_id != job_id: + return block_payload(event_name, "Hermes Context Firewall: result job_id does not match bound agent_id.") + existing = load_sanitized_result(task_dir, job_id) + terminal_state = initial_state.get("status") in {"blocked", "failed", "stale"} + if existing is None and raw_result and not terminal_state: + try: + accept_result_text(task_dir, task_data, root, job_id, raw_result) + except DispatchError as exc: + try: + state = load_dispatch(task_dir, job_id) + except DispatchError: + state = {} + if event_name == "SubagentStop" and state.get("status") != "blocked": + return block_payload( + event_name, + f"Hermes Context Firewall: rewrite the final JSON result ({exc.code}).", + ) + elif existing is None: + try: + state = load_dispatch(task_dir, job_id) + except DispatchError: + state = {} + if state.get("status") not in {"blocked", "failed", "stale"}: + return block_payload(event_name, "Hermes Context Firewall: final Agent result is missing.") + + if event_name == "PostToolUse": + tool_response = data.get("tool_response") + if isinstance(tool_response, dict) and tool_response.get("status") == "async_launched": + return updated_tool_output_payload( + sanitized_summary(task_dir, job_id), + tool_response, + reason="Hermes Context Firewall: background Agent output cannot satisfy a validated dispatch.", + ) + return updated_tool_output_payload( + sanitized_summary(task_dir, job_id), + tool_response, + ) + return None + + +def handle_subagent_start_event( + root: Path, + task: str, + task_data: dict[str, Any], + data: dict[str, Any], +) -> dict[str, Any] | None: + agent_type, agent_id = agent_event_identity(data, "SubagentStart") + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import ( # type: ignore[import-not-found] + DispatchError, + bind_claude_agent, + role_for_agent_type, + ) + except ImportError: + return block_payload("SubagentStart", "Hermes Context Firewall runtime is unavailable.") + if role_for_agent_type(agent_type) is None: + return None + if not agent_id or not agent_type: + return block_payload("SubagentStart", "Hermes Agent start is missing agent identity.") + try: + dispatch = bind_claude_agent( + root / ".trellis" / "tasks" / task, + task_data, + root, + agent_type=agent_type, + agent_id=agent_id, + session_id=str(data.get("session_id") or "") or None, + ) + except DispatchError as exc: + return block_payload("SubagentStart", f"Hermes Context Firewall: {exc.code}.") + return context_payload( + "SubagentStart", + f"Hermes binding active for job_id={dispatch['job_id']}; return only its Result Envelope.", + ) + + +def closure_dispatch_stop_reason(task_dir: Path, task_data: dict[str, Any]) -> str | None: + dispatches = task_dir / "hermes" / "dispatches" + if not dispatches.is_dir(): + return None + if task_data.get("closure_state") == "closed" and task_data.get("hermes_phase") == "closed": + return None + unfinished = {"created", "validated", "running", "result_returned", "rewrite_required"} + for path in dispatches.glob("*.dispatch.json"): + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "Hermes Context Firewall: dispatch state is invalid; repair it before stopping." + if value.get("status") in unfinished: + return "Hermes Context Firewall: a dispatch result is still unconfirmed." + packages = [item for item in task_data.get("work_packages") or [] if isinstance(item, dict)] + if any(item.get("status") not in {"done", "deferred", "waived"} for item in packages): + return "Hermes Context Firewall: work packages remain undisposed." + return "Hermes Context Firewall: closure conditions are ready; run closure audit/close before stopping." + + def main() -> int: if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": return 0 @@ -1253,14 +1760,44 @@ def main() -> int: return 0 event_name = str(data.get("hook_event_name") or "") + platform = detect_platform(data) runtime_root = root / ".trellis" / "scripts" / "hermes" task = resolve_active_task(root, data) + task_data = read_active_task_data(root, task) + record_hook_heartbeat( + root, + platform, + task_id=str(task_data.get("id") or task or "") or None, + session_id=str(data.get("session_id") or "") or None, + ) + if task and not is_hermes_closure_task(task_data): + return 0 worker_records = ( root / ".trellis" / "tasks" / task / "hermes" / "worker_records.jsonl" if task else None ) + if event_name == "SubagentStart" and task: + payload = handle_subagent_start_event(root, task, task_data, data) + if payload is not None: + print(json.dumps(payload, ensure_ascii=False)) + return 0 + + if event_name == "PostToolUse" and task: + tool_name = str(data.get("tool_name") or data.get("toolName") or "") + if tool_name.casefold() in {"agent", "task"}: + payload = handle_agent_result_event(root, task, task_data, data, event_name) + if payload is not None: + print(json.dumps(payload, ensure_ascii=False)) + return 0 + + if event_name == "SubagentStop" and task: + payload = handle_agent_result_event(root, task, task_data, data, event_name) + if payload is not None: + print(json.dumps(payload, ensure_ascii=False)) + return 0 + if event_name in {"Stop", "SubagentStop"}: if not task or worker_records is None: return 0 @@ -1281,6 +1818,13 @@ def main() -> int: )) return 0 if event_name == "Stop": + closure_reason = closure_dispatch_stop_reason( + root / ".trellis" / "tasks" / task, + task_data, + ) + if closure_reason is not None: + print(json.dumps(block_payload(event_name, closure_reason), ensure_ascii=False)) + return 0 records = read_worker_records(worker_records) reason = stop_completion_reason(root, runtime_root, task, records) if reason is not None: @@ -1294,7 +1838,7 @@ def main() -> int: if reason is not None: print(json.dumps(deny_tool_payload(event_name, reason), ensure_ascii=False)) return 0 - if tool_name == "Bash": + if tool_name == "Bash" or tool_name in READ_TOOL_NAMES: return 0 if not task or worker_records is None: if tool_name in WRITE_TOOL_NAMES: @@ -1332,6 +1876,11 @@ def main() -> int: ) print(json.dumps(context_payload(event_name, context), ensure_ascii=False)) return 0 + if tool_name in READ_TOOL_NAMES: + reason = guard_agent_read_permissions(root, task, data) + if reason is not None: + print(json.dumps(deny_tool_payload(event_name, reason), ensure_ascii=False)) + return 0 return 0 diff --git a/packages/cli/src/templates/shared-hooks/inject-subagent-context.py b/packages/cli/src/templates/shared-hooks/inject-subagent-context.py index 39b11e7..1becbc5 100644 --- a/packages/cli/src/templates/shared-hooks/inject-subagent-context.py +++ b/packages/cli/src/templates/shared-hooks/inject-subagent-context.py @@ -28,6 +28,7 @@ import json import os +import re import sys from pathlib import Path from typing import Any @@ -61,6 +62,26 @@ AGENT_CODE_REVIEW = "trellis-code-review" AGENT_CODE_ARCHITECTURE_REVIEW = "trellis-code-architecture-review" AGENT_MERGE_REVIEW = "trellis-merge-review" +AGENT_HERMES_PLANNER = "hermes-planner" +AGENT_HERMES_RESEARCHER = "hermes-researcher" +AGENT_HERMES_CODER = "hermes-coder" +AGENT_HERMES_RUNNER = "hermes-runner" +AGENT_HERMES_REVIEWER = "hermes-reviewer" + +HERMES_AGENT_ROLES = { + AGENT_HERMES_PLANNER: "planner", + AGENT_HERMES_RESEARCHER: "researcher", + AGENT_HERMES_CODER: "coder", + AGENT_HERMES_RUNNER: "runner", + AGENT_HERMES_REVIEWER: "reviewer", +} +HERMES_LEGACY_AGENTS = { + "hermes-scientist": ("planner", "research_design"), + "hermes-literature": ("researcher", "literature"), + "hermes-evaluator": ("reviewer", "evidence"), + "hermes-claim-reviewer": ("reviewer", "claim"), +} +HERMES_AGENTS = (*HERMES_AGENT_ROLES, *HERMES_LEGACY_AGENTS) CLAUDE_SHARED_WORKTREE_MARKER = "/.trellis/trellis-worktrees/" @@ -73,10 +94,16 @@ AGENTS_CHECK_CONTEXT = (AGENT_CHECK, *AGENTS_REVIEW) # Agents that require a task directory -AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, *AGENTS_CHECK_CONTEXT) +AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, *AGENTS_CHECK_CONTEXT, *HERMES_AGENTS) # All supported agents AGENTS_ALL = (*AGENTS_REQUIRE_TASK, AGENT_RESEARCH) +HERMES_BASE_SUMMARY = ( + "Use task.json and append-only records as state; chat claims do not change state. " + "Stay inside task-card permissions, cite evidence, keep output compact, and block " + "high-risk research changes or missing human approval." +) + def find_repo_root(start_path: str) -> str | None: """ @@ -669,6 +696,222 @@ def get_research_context(repo_root: str, task_dir: str | None) -> str: return "\n\n".join(context_parts) +def _load_role_helpers(repo_root: str): + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.roles import ( # type: ignore[import-not-found] + DEFAULT_PROFILES, + normalize_task_card, + profile_summary, + role_summary, + ) + + return DEFAULT_PROFILES, normalize_task_card, profile_summary, role_summary + except Exception: + return None + + +def _load_task_json(repo_root: str, task_dir: str) -> dict[str, Any]: + path = Path(repo_root) / task_dir / FILE_TASK_JSON + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _active_task_card( + repo_root: str, + task_dir: str, + role: str, + job_id: str | None = None, +) -> dict[str, Any] | None: + path = Path(repo_root) / task_dir / "hermes" / "worker_records.jsonl" + if not path.is_file(): + return None + records: list[dict[str, Any]] = [] + try: + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + value = json.loads(line) + if isinstance(value, dict): + records.append(value) + except (OSError, json.JSONDecodeError): + return None + terminal = { + str(item.get("job_id")) + for item in records + if item.get("type") in {"result", "rejection", "stalled"} + and isinstance(item.get("job_id"), str) + } + helpers = _load_role_helpers(repo_root) + candidates: list[dict[str, Any]] = [] + for item in records: + if item.get("type") != "task_card" or item.get("job_id") in terminal: + continue + normalized = item + if helpers is not None: + try: + normalized, _warnings = helpers[1](item) + except ValueError: + continue + if normalized.get("role") == role: + candidates.append(normalized) + if job_id: + return next( + (card for card in reversed(candidates) if card.get("job_id") == job_id), + None, + ) + return candidates[0] if len(candidates) == 1 else None + + +def _dispatch_job_id(prompt: str) -> str | None: + stripped = prompt.strip() + match = re.fullmatch( + r"(?i)(?:(?:job_id|job)\s*[:=]\s*)?([A-Za-z0-9][A-Za-z0-9._-]{0,79})", + stripped, + ) + return match.group(1) if match else None + + +def _explicit_dispatch_job_id(tool_input: dict[str, Any], prompt: str) -> str | None: + for key in ("job_id", "jobId", "hermes_job_id", "hermesJobId"): + value = tool_input.get(key) + if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}", value.strip()): + return value.strip() + return _dispatch_job_id(prompt) + + +def _async_agent_requested(tool_input: dict[str, Any]) -> bool: + for key in ("run_in_background", "background", "async", "asynchronous"): + value = tool_input.get(key) + if value is True or (isinstance(value, str) and value.casefold() in {"true", "yes", "async", "background"}): + return True + mode = tool_input.get("mode") + return isinstance(mode, str) and mode.casefold() in {"async", "background"} + + +def _is_hermes_closure_task(task: dict[str, Any]) -> bool: + return bool(task) and any( + key in task for key in ("closure_state", "hermes_phase", "work_packages") + ) + + +def _prepare_claude_firewall_dispatch( + repo_root: str, + task_dir: str, + subagent_type: str, + original_prompt: str, + tool_input: dict[str, Any], + session_id: str | None, +) -> str: + if _async_agent_requested(tool_input): + raise ValueError("Hermes Agent dispatch must be synchronous so a final result can be validated.") + task = _load_task_json(repo_root, task_dir) + if not _is_hermes_closure_task(task): + raise ValueError("Legacy agent aliases are available only inside a Hermes closure task.") + job_id = _explicit_dispatch_job_id(tool_input, original_prompt) + if not job_id: + raise ValueError("Hermes Agent accepts only a validated job_id; create the dispatch first.") + helpers = _load_role_helpers(repo_root) + if helpers is None: + raise ValueError("Hermes role helpers are unavailable.") + if subagent_type in HERMES_AGENT_ROLES: + role = HERMES_AGENT_ROLES[subagent_type] + else: + role = HERMES_LEGACY_AGENTS[subagent_type][0] + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import prepare_dispatch_for_agent # type: ignore[import-not-found] + + dispatch = prepare_dispatch_for_agent( + Path(repo_root) / task_dir, + task, + Path(repo_root), + job_id, + platform="claude", + role=role, + session_id=session_id, + ) + except (ImportError, ValueError) as exc: + raise ValueError(str(exc)) from exc + return str(dispatch["body"]) + + +def _compact_capsule(repo_root: str, task_dir: str, task: dict[str, Any]) -> str: + scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.closure import build_capsule # type: ignore[import-not-found] + + return build_capsule(Path(repo_root) / task_dir, task, Path(repo_root)) + except Exception: + title = str(task.get("title") or Path(task_dir).name) + status = str(task.get("status") or "planning") + return f"Task: {task.get('id') or Path(task_dir).name} | {title}\nStatus: {status}" + + +def get_hermes_minimal_context( + repo_root: str, + task_dir: str, + subagent_type: str, + original_prompt: str, +) -> str: + helpers = _load_role_helpers(repo_root) + if helpers is None: + return "" + defaults, _normalize_task_card, get_profile_summary, get_role_summary = helpers + migration = "" + if subagent_type in HERMES_AGENT_ROLES: + role = HERMES_AGENT_ROLES[subagent_type] + profile = defaults[role] + else: + role, profile = HERMES_LEGACY_AGENTS[subagent_type] + canonical_agent = f"hermes-{role}" + migration = f"Legacy alias: use {canonical_agent} with profile {profile}.\n" + card = _active_task_card( + repo_root, + task_dir, + role, + _dispatch_job_id(original_prompt), + ) + if card is not None: + profile = str(card.get("profile") or profile) + task = _load_task_json(repo_root, task_dir) + capsule = _compact_capsule(repo_root, task_dir, task) + return ( + "\n" + f"{migration}Base: {HERMES_BASE_SUMMARY}\n" + f"Role: {role} - {get_role_summary(role)}\n" + f"Profile: {profile} - {get_profile_summary(role, profile)}\n" + "Task Capsule:\n" + f"{capsule}\n" + "" + ) + + +def build_hermes_prompt(original_prompt: str, context: str) -> str: + return f""" +# Hermes Role Task + +{context} + +## Assignment + +{original_prompt} + +Use only this compact context initially. Read a referenced file only when the +assignment cannot be completed without it. Do not load complete task history, +all specifications, or all Hermes ledgers by default. Do not spawn another +sub-agent; return the required record or result to the main agent.""" + + def build_research_prompt(original_prompt: str, context: str) -> str: """Build complete prompt for Research""" return f"""# Research Agent Task @@ -877,7 +1120,7 @@ def _normalize_hook_text(value: Any) -> str: def is_claude_code_dev_agent(subagent_type: str) -> bool: - return subagent_type in (AGENT_IMPLEMENT, *AGENTS_CHECK_CONTEXT) + return subagent_type in (AGENT_IMPLEMENT, *AGENTS_CHECK_CONTEXT, AGENT_HERMES_CODER) def _extract_strategy_field(text: str, *labels: str) -> str | None: @@ -1153,6 +1396,8 @@ def main(): is_finish_phase = "[finish]" in original_prompt.lower() # Get context and build prompt based on subagent type + context = "" + new_prompt = original_prompt if subagent_type == AGENT_IMPLEMENT: assert task_dir is not None # validated above context = get_implement_context(repo_root, task_dir) @@ -1175,6 +1420,37 @@ def main(): # Research can work without task directory context = get_research_context(repo_root, task_dir) new_prompt = build_research_prompt(original_prompt, context) + elif subagent_type in HERMES_AGENTS and platform == "claude": + assert task_dir is not None + try: + if _async_agent_requested(normalized_tool_input): + raise ValueError( + "Hermes Agent dispatch must be synchronous so a final result can be validated." + ) + normalized_tool_input = { + **normalized_tool_input, + "run_in_background": False, + } + context = "validated-dispatch" + new_prompt = _prepare_claude_firewall_dispatch( + repo_root, + task_dir, + subagent_type, + original_prompt, + normalized_tool_input, + str(input_data.get("session_id") or "") or None, + ) + except ValueError as exc: + deny_pretooluse(f"Hermes Context Firewall: {exc}") + elif subagent_type in HERMES_AGENTS: + assert task_dir is not None + context = get_hermes_minimal_context( + repo_root, + task_dir, + subagent_type, + original_prompt, + ) + new_prompt = build_hermes_prompt(original_prompt, context) else: sys.exit(0) 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 6da1c90..1f1d93b 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -65,13 +65,35 @@ If you have not already loaded Trellis context this session, read the `trellis-start` skill once. """ +PROJECT_CONTEXT_REFS = ( + ("Project background", "BACKGROUND.md", "project origin and objective"), + ("Research plan", "RESEARCH_PLAN.md", "research approach and evidence limits"), + ("Project constraints", "CONSTRAINTS.md", "fixed boundaries and approvals"), +) + + +def _build_project_context_index(root: Path) -> str: + """List main-agent project inputs without loading the documents themselves.""" + project_dir = root / ".trellis" / "project" + lines = [ + "", + "Main-agent planning input. Read a relevant document on demand before accepting or splitting a request; do not pass its full content to subagents by default.", + ] + for title, filename, purpose in PROJECT_CONTEXT_REFS: + state = "available" if (project_dir / filename).is_file() else "missing" + lines.append( + f"- {title}: .trellis/project/{filename} [{state}] - {purpose}." + ) + lines.append("") + return "\n".join(lines) + def _build_codex_hermes_boot_guard_notice(root: Path) -> str: guard_path = root / ".trellis" / "hermes" / "HERMES_MAIN_AGENT_BOOT_GUARD.md" if not guard_path.is_file(): return "" return """ -Hermes main-agent mode: coordinate state, route bounded work to subagents, require structured records, and stop at human/PI gates. Full rules: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Context policy: minimal_file_context. +Hermes main-agent mode: coordinate state, route bounded work through validated dispatches, require sanitized Result Envelopes, and stop at human/PI gates. Full rules: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Formal Claude/Codex context policy: validated_dispatch_only. """ @@ -177,28 +199,99 @@ def get_task_capsule(root: Path, input_data: dict) -> str | None: task_dir = Path(active.task_path) if not task_dir.is_absolute(): task_dir = root / task_dir - task_json = task_dir / "task.json" - if not task_json.is_file(): - return None try: - data = json.loads(task_json.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return None - if not isinstance(data, dict) or not any( - field in data for field in ("closure_state", "hermes_phase", "work_packages") - ): + task = json.loads((task_dir / "task.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): return None scripts_dir = root / ".trellis" / "scripts" if str(scripts_dir) not in sys.path: sys.path.insert(0, str(scripts_dir)) try: - from common.closure import build_capsule # type: ignore[import-not-found] - - return build_capsule(task_dir, data, root) + from common.closure import build_capsule, is_closure_task # type: ignore[import-not-found] except Exception: return None + if not isinstance(task, dict) or not is_closure_task(task): + return None + return build_capsule(task_dir, task, root) +def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None] | None: + """Return closure-specific, revision-aware per-turn context.""" + active = _resolve_active_task(root, input_data) + if not active.task_path or active.stale: + return None + task_dir = Path(active.task_path) + if not task_dir.is_absolute(): + task_dir = root / task_dir + try: + data = json.loads((task_dir / "task.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.closure import ( # type: ignore[import-not-found] + build_capsule, + closure_next_action, + is_closure_task, + research_route_rule, + research_route_summary, + ) + except Exception: + return None + if not isinstance(data, dict) or not is_closure_task(data): + return None + task_id = str(data.get("id") or task_dir.name) + revision = data.get("hermes_revision", 0) + phase = str(data.get("hermes_phase") or "planning") + next_action = closure_next_action(data) + route_summary = research_route_summary(data) + route_rule = research_route_rule(data) + key = getattr(active, "context_key", None) + anchor_path = root / ".trellis" / ".runtime" / "sessions" / f"{key}.json" if isinstance(key, str) and key else None + anchor: dict = {} + if anchor_path and anchor_path.is_file(): + try: + anchor = json.loads(anchor_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + anchor = {} + previous = anchor.get("closure_context_anchor") if isinstance(anchor, dict) else None + current = { + "task": task_id, + "revision": revision, + "phase": phase, + "next_action": next_action, + "route": route_summary, + } + if anchor_path: + try: + anchor_path.parent.mkdir(parents=True, exist_ok=True) + anchor["closure_context_anchor"] = current + anchor_path.write_text(json.dumps(anchor, ensure_ascii=False) + "\n", encoding="utf-8") + except OSError: + pass + if isinstance(previous, dict) and previous.get("task") == task_id and previous.get("revision") == revision: + return ( + f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\n", + None, + ) + if isinstance(previous, dict) and previous.get("task") == task_id: + changes = [f"Hermes closure update: {task_id}; revision {previous.get('revision')} -> {revision}."] + for field, label in (("phase", "Phase"), ("next_action", "Next")): + if previous.get(field) != current[field]: + changes.append(f"{label}: {current[field]}") + if previous.get("route") != current["route"]: + changes.append(route_summary) + changes.append("Route rule: " + route_rule) + return ( + "\n" + "\n".join(changes) + "\n", + build_capsule(task_dir, data, root), + ) + return ( + f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nNext: {next_action}\n", + build_capsule(task_dir, data, root), + ) # --------------------------------------------------------------------------- # Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults # --------------------------------------------------------------------------- @@ -396,9 +489,12 @@ def main() -> int: templates = load_breadcrumbs(root) config = _read_trellis_config(root) platform = _detect_platform(data) + closure_context = _closure_turn_context(root, data) task = get_active_task(root, data) - capsule = get_task_capsule(root, data) - if task is None: + capsule = None + if closure_context is not None: + breadcrumb, capsule = closure_context + elif task is None: # No active task — still emit a breadcrumb nudging AI toward # trellis-brainstorm + task.py create when user describes real work. no_task_key = resolve_breadcrumb_key("no_task", platform, config) @@ -419,6 +515,7 @@ def main() -> int: boot_guard_notice = _build_codex_hermes_boot_guard_notice(root) if boot_guard_notice: parts.append(boot_guard_notice) + parts.append(_build_project_context_index(root)) if task is None: parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) parts.append(_codex_mode_banner(config)) diff --git a/packages/cli/src/templates/shared-hooks/session-start.py b/packages/cli/src/templates/shared-hooks/session-start.py index 21ce6f4..500eeb0 100644 --- a/packages/cli/src/templates/shared-hooks/session-start.py +++ b/packages/cli/src/templates/shared-hooks/session-start.py @@ -72,6 +72,24 @@ def _normalize_windows_shell_path(path_str: str) -> str: This notice is one-shot: do not repeat it after the first assistant reply in the same session. """ +PROJECT_CONTEXT_REFS = ( + ( + "Project background", + "BACKGROUND.md", + "project origin, domain, current state, and objective", + ), + ( + "Research plan", + "RESEARCH_PLAN.md", + "research question, current approach, evidence standard, and limits", + ), + ( + "Project constraints", + "CONSTRAINTS.md", + "fixed boundaries, compatibility, data, resources, and approvals", + ), +) + # Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is # cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) # both in stdin (hook payload from host CLI) and stdout (our emitted blocks) @@ -278,12 +296,32 @@ def _build_hermes_main_agent_boot_guard(trellis_dir: Path) -> str: Source: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Read it before governance-sensitive work; keep this SessionStart payload compact. Role: Main Agent / Main Pilot coordinates Hermes state, bounded subagent routing, record validation, handoff, and human/PI stop points. Hard limits: do not directly modify source, metrics, dataset splits, baselines, official evaluation, claim_allowed=true, or main merge unless Hermes state and human/root authority allow it. -Subagent context policy: minimal_file_context. Give role, task ID, allowed files, forbidden files, required output, stop condition, record schema, and claim boundary. Do not fork full chat by default. +Formal Claude/Codex context policy: validated_dispatch_only. Give Agent only a validated job_id; keep raw output out of chat. Do not fork full chat by default. Record policy: chat is not completion; completed, failed, blocked, stale, interrupted, or capacity-blocked work needs structured records. -Claim boundary: keep engineering success, runner success, evaluator approval, proxy evidence, scientific evidence, claim approval, and merge approval separate. +Claim boundary: keep engineering success, runner success, evidence review, proxy evidence, scientific evidence, claim approval, and merge approval separate. """ +def _build_project_context_index(trellis_dir: Path) -> str: + """List project-level documents without reading their contents.""" + project_dir = trellis_dir / "project" + lines = [ + "", + "Main-agent planning input. Read only the relevant document before accepting, discussing, or splitting a request; do not inject document contents by default.", + ] + for title, filename, purpose in PROJECT_CONTEXT_REFS: + path = project_dir / filename + state = "available" if path.is_file() else "missing" + lines.append( + f"- {title}: .trellis/project/{filename} [{state}] - {purpose}." + ) + lines.append( + "Subagents receive a project-context document only when it is an explicit allowed ref in their validated dispatch." + ) + lines.append("") + return "\n".join(lines) + + def _repo_relative(repo_root: Path, path: Path) -> str: try: return path.relative_to(repo_root).as_posix() @@ -879,6 +917,20 @@ def _build_workflow_overview(workflow_path: Path) -> str: return "\n".join(out_lines).rstrip() +def _record_context_firewall_heartbeat(project_dir: Path, platform: str | None) -> None: + if platform != "claude": + return + scripts_dir = project_dir / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.firewall import record_firewall_heartbeat # type: ignore[import-not-found] + + record_firewall_heartbeat(project_dir, "claude", "hooks") + except Exception: + pass + + def main(): if should_skip_injection(): sys.exit(0) @@ -910,6 +962,8 @@ def main(): if project_dir is None: project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() + _record_context_firewall_heartbeat(project_dir, _detect_platform(hook_input)) + worktree_sync_notice = _maybe_sync_trellis_worktree(project_dir) trellis_dir = project_dir / ".trellis" context_key = _resolve_context_key(trellis_dir, hook_input) @@ -944,6 +998,9 @@ def main(): output.write(hermes_boot_guard) output.write("\n\n") + output.write(_build_project_context_index(trellis_dir)) + output.write("\n\n") + output.write("\n") output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) output.write("\n\n\n") diff --git a/packages/cli/src/templates/trellis/config.yaml b/packages/cli/src/templates/trellis/config.yaml index 220ed96..707907b 100644 --- a/packages/cli/src/templates/trellis/config.yaml +++ b/packages/cli/src/templates/trellis/config.yaml @@ -86,3 +86,14 @@ max_journal_lines: 2000 # # codex: # dispatch_mode: sub-agent # "inline" requires the Hermes runtime guard to be inactive + +#------------------------------------------------------------------------------- +# Hermes Agent Context Firewall +#------------------------------------------------------------------------------- +# Claude uses approved Agent hooks for enforced prompt replacement and result +# sanitization. Codex native custom agents are advisory; strict mode invokes +# codex exec with the bundled result schema. Lean warns without a live +# heartbeat, standard requires hooks or strict, and publication fails closed. +# +# hermes: +# codex_mode: native # native (advisory) | strict (enforced) 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 75409da..b76827a 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 @@ -30,6 +30,25 @@ current routing budget is up to 5 files. After the relevant implementation, execution, review, research, or analysis surface is identified, dispatch the work instead of continuing directly. +Every task still requires a short main-agent analysis before planning or +dispatch: identify the intent, scope, completion conditions, route, risks, and +the smallest useful work-package split. This is evidence-based planning, not a +default interview. Start a grill only when an important solution, architecture, +research-design, or scope decision remains unresolved, or when the user +explicitly asks for one. Task size, task age, and the mere presence of multiple +work packages are not reasons to start a grill. + +At session start, use the project-context index for `.trellis/project/` before +accepting or splitting a request. Read the relevant background, research-plan, +and constraint document on demand; their contents must not be copied into the +startup prompt or sent to a subagent unless a validated dispatch explicitly +needs one of them. + +When creating a dispatch, the firewall adds the smallest matching project +context for its role and profile only after explicit task refs and context pins. +Use the remaining refs for the task-specific spec or evidence that the worker +needs. Do not add all project documents merely because they exist. + ## 2. Main Agent Hard Limits As Main Agent, you must not directly: @@ -62,12 +81,13 @@ If code has been changed, route review to an independent **reviewer subagent** b If commands must be run, route execution to a **runner subagent**. -If evidence quality must be judged, route judgment to an **evaluator subagent**. +If evidence quality must be judged, route judgment to a **reviewer subagent** with profile `evidence`. -If literature, novelty, or codebase exploration is needed, route it to a **research/scout subagent**. +If literature, novelty, official documentation, or codebase exploration is needed, route it to a **researcher subagent** with the matching profile. If root-cause analysis, architecture tradeoffs, or failure explanation is -needed, route it to an **analyst subagent**. +needed, route it to a **planner subagent** with profile `root_cause` or +`method_selection`. ## 3. Subagent Governance @@ -75,8 +95,12 @@ Subagents are bounded workers, not independent project owners. Each subagent must have: +* a validated `job_id` dispatch; * a role; +* a profile; * a task ID; +* a bound `hermes_revision`; +* the current work package for execution roles; * explicit input files; * explicit allowed files; * explicit forbidden files; @@ -90,28 +114,43 @@ Subagents must not receive the full old chat history by default. Default policy: ```text -subagent_context_policy = minimal_file_context +subagent_context_policy = validated_dispatch_only ``` -Subagents should read only the files they need: +The main agent creates a validated dispatch and passes only its `job_id` to +Claude Agent. Codex native dispatch is advisory; enforced Codex work uses the +strict `dispatch.py run` wrapper. The canonical body contains role/profile, +revision, package, objective, and at most three refs within 2000 characters. -* task spec; -* role policy; -* current state; -* relevant records; -* relevant evidence; -* allowed / forbidden file lists; -* required output schema. +The subagent reads a referenced file only when the assignment needs it. Full +PRDs, all historical reports, all events, all ledgers, and all specifications +are not default dispatch context. Do not default to `fork_turns=all`. -Full-context transfer is allowed only for context-compression or handoff-generation tasks, not for coder, reviewer, runner, evaluator, or research work. +Full-context transfer is allowed only for context-compression or handoff-generation tasks, not for planner, researcher, coder, runner, or reviewer work. + +Each dispatch is single-purpose. Once its Result Envelope and required records +are accepted, close the subagent immediately. Do not keep an idle subagent for +the next work package, assign it a second job, or reuse its chat context; create +a new dispatch and a new subagent when more work is needed. ## 4. Separation of Roles Maintain strict maker-checker separation. -### Builder / coder subagent +### Planner subagent + +Owns research design, task planning, root-cause analysis, and method selection. +It proposes bounded changes but does not approve high-risk research changes, +complete packages, or close tasks. + +### Researcher subagent + +Owns literature, codebase, official documentation, and prior-art searches. It +does not modify code, results, claims, approvals, or closure state. + +### Coder subagent Owns code mutation. In current RecordBus task cards this role is recorded as `coder`; host-level `builder` is treated as the same mutation owner. It may @@ -156,34 +195,17 @@ Must not modify source code. Runner output is execution evidence, not scientific approval. -### Evaluator subagent - -May judge evidence quality and state-transition readiness. - -Must not patch code. - -Must not set `claim_allowed=true`. - -Must not convert proxy evidence into scientific claims. - -### Research / scout subagent - -Owns external/source reading. It may inspect code, papers, docs, and novelty risks. - -Must be read-only unless explicitly scoped otherwise. - -### Analyst subagent - -Owns root-cause analysis, tradeoffs, and decision framing. - -Must be read-only unless explicitly scoped otherwise. +Reviewer profiles determine whether it checks quality, evidence, claims, +safety, closure, or statistics. Evidence judgment must not patch code, set +`claim_allowed=true`, or convert proxy evidence into a scientific claim. ## 5. Hook Enforcement -`PreToolUse` is the role firewall. It denies main-agent mutation, execution, -package/test commands, git mutation, and removal commands, then points to the -right subagent. Explicit subagents still pass through the existing `task_card` -and `allowed_files` / `forbidden_files` checks. +`PreToolUse` is the role firewall. For Claude Agent it accepts only a validated +`job_id`, rejects async dispatch, and replaces the original prompt with the +canonical body. `SubagentStop` validates the Result Envelope; `PostToolUse` +returns only its sanitized summary. Explicit writers still pass through +`task_card` and file-boundary checks. `Stop` is read-only. It reads RecordBus, git diff, and `run_manifest.jsonl`. It must not write records or run tests. @@ -196,6 +218,11 @@ Every completed, failed, blocked, stale, interrupted, or capacity-blocked task m No record means no completion. +After recording a completed, failed, blocked, stale, interrupted, or +capacity-blocked result, close the subagent. A missing or invalid record may be +rewritten within the dispatch limit, but it does not justify leaving the worker +open after that limit is reached. + Records should include: * role; @@ -212,6 +239,7 @@ Records should include: * risk flags; * next recommendation; * claim boundary. +* uncertainties. You may summarize records, but you must not replace them with informal chat. @@ -249,7 +277,7 @@ Hermes distinguishes: * engineering success; * runner success; -* evaluator approval; +* evidence review; * proxy evidence; * scientific evidence; * claim approval; @@ -260,8 +288,9 @@ Do not collapse these categories. Examples: * `template_only` is not evidence of small_proxy success. -* runner command success is not evaluator approval. -* evaluator approval is not `claim_allowed`. +* runner command success is not evidence and must cite run manifests through `run_refs`. +* evidence review is not `claim_allowed`. +* evidence/claim reviewers propose judgments; they do not approve facts. * proxy success is not scientific improvement. * `claim_allowed=true` requires human / PI approval. * main merge requires human / PI approval. diff --git a/packages/cli/src/templates/trellis/hermes/config.yaml b/packages/cli/src/templates/trellis/hermes/config.yaml index dd96433..772ed74 100644 --- a/packages/cli/src/templates/trellis/hermes/config.yaml +++ b/packages/cli/src/templates/trellis/hermes/config.yaml @@ -23,6 +23,8 @@ records: metrics_schema: ".trellis/tasks/{task}/hermes/metrics_schema.yaml" compare_records: ".trellis/tasks/{task}/hermes/compare.jsonl" report: ".trellis/tasks/{task}/hermes/report.md" + dispatches: ".trellis/tasks/{task}/hermes/dispatches" + dispatch_audit: ".trellis/tasks/{task}/hermes/dispatch_audit.jsonl" global_templates: records_readme: ".trellis/hermes/records/README.md" @@ -53,6 +55,19 @@ recordbus: chat_is_fact_source: false completion_requires_record: true +context_firewall: + schema: "hermes-dispatch/v1" + result_schema: ".trellis/hermes/schemas/result-envelope.schema.json" + formal_platforms: ["claude", "codex"] + max_refs: 3 + max_dispatch_chars: 2000 + max_conclusion_chars: 1200 + raw_trace_dir: ".trellis/.runtime/hermes-traces" + raw_trace_publish: false + claude_mode: "hooks-enforced" + codex_native: "advisory" + codex_strict: "codex exec --output-schema --json -o" + async_workers: heartbeat_interval: "5m" timeout_policy: "mark stalled, resume from the latest checkpoint, or dispatch a replacement worker" @@ -68,6 +83,11 @@ runtime: note: "Local fail-closed checks only; not an OS sandbox." commands: append_record: "python3 ./.trellis/scripts/hermes/record.py append --task {task} --record-type worker --json '{json}'" + dispatch_create: "python3 ./.trellis/scripts/hermes/dispatch.py create --task {task} --role {role} --profile {profile} --objective {objective}" + dispatch_validate: "python3 ./.trellis/scripts/hermes/dispatch.py validate --task {task} --job-id {job_id}" + dispatch_show: "python3 ./.trellis/scripts/hermes/dispatch.py show --task {task} --job-id {job_id}" + dispatch_run_codex_strict: "python3 ./.trellis/scripts/hermes/dispatch.py run --task {task} --job-id {job_id} --platform codex --mode strict" + dispatch_list: "python3 ./.trellis/scripts/hermes/dispatch.py list --task {task}" validate_worker: "python3 ./.trellis/scripts/hermes/validate.py --task {task} --kind worker" init_experiment: "python3 ./.trellis/scripts/hermes/experiment.py init --task {task}" validate_experiment: "python3 ./.trellis/scripts/hermes/experiment.py validate --task {task}" @@ -80,6 +100,9 @@ runtime: runner_run: "python3 ./.trellis/scripts/hermes/runner.py run --task {task} --job-id {job_id} --checkpoint {checkpoint} --summary {summary} --resume-from {resume_from} -- {command}" runner_replay: "python3 ./.trellis/scripts/hermes/runner.py replay --task {task} --run-id {run_id}" validate_run_manifest: "python3 ./.trellis/scripts/hermes/validate.py --task {task} --kind run_manifest" + evidence_collect: "python3 ./.trellis/scripts/hermes/evidence.py collect --task {task}" + evidence_validate: "python3 ./.trellis/scripts/hermes/evidence.py validate --task {task}" + evidence_summary: "python3 ./.trellis/scripts/hermes/evidence.py summary --task {task}" aggregate: "python3 ./.trellis/scripts/hermes/report.py aggregate --task {task} --output .trellis/tasks/{task}/hermes/aggregate.json" compare: "python3 ./.trellis/scripts/hermes/report.py compare --task {task} --metric {metric} --baseline {baseline} --new {new} --threshold {threshold} --direction {direction}" report: "python3 ./.trellis/scripts/hermes/report.py report --task {task} --question {question} --method {method} --data {data} --metrics {metrics} --limitations {limitations} --risks {risks}" diff --git a/packages/cli/src/templates/trellis/hermes/records/recordbus.md b/packages/cli/src/templates/trellis/hermes/records/recordbus.md index 62ead7c..781a580 100644 --- a/packages/cli/src/templates/trellis/hermes/records/recordbus.md +++ b/packages/cli/src/templates/trellis/hermes/records/recordbus.md @@ -16,18 +16,28 @@ communicator. It may read RecordBus, git status/diff/log, test results, and up to 5 files for routing. Once the implementation surface is identified, the main agent must dispatch the bounded work instead of doing it directly. -Use task cards to assign one bounded worker at a time. The supported worker -roles for task cards are `coder`, `runner`, `evaluator`, `reviewer`, -`literature`, `researcher`, and `analyst`. Host-level `builder` identity maps -to the `coder` mutation owner. - -- `builder` / `coder` owns code mutation inside `allowed_files`. -- `runner` executes commands or tests and reports structured output. -- `evaluator` compares outputs against the requested evidence standard. -- `reviewer` owns quality and security review before handoff. -- `researcher` / `literature` gathers outside sources, citations, and source - reading evidence. -- `analyst` owns root-cause analysis, tradeoff analysis, and decision framing. +Formal Agent Context Firewall support is limited to Claude and Codex. A +validated dispatch lives at +`.trellis/tasks//hermes/dispatches/.dispatch.json`; its sanitized +result lives beside it as `.result.json`. Claude hooks replace Agent input +and output. Codex native agents are advisory; strict mode uses `codex exec +--output-schema --json -o` through `dispatch.py run`. + +Use task cards to assign one bounded worker at a time. Canonical roles are +`planner`, `researcher`, `coder`, `runner`, and `reviewer`. The optional +`profile` selects a focused mode inside that role without creating another +agent template. + +- `planner` owns research design, task planning, method selection, and root-cause analysis. +- `researcher` gathers codebase, literature, official documentation, or prior-art sources. +- `coder` owns code, test, and configuration mutation inside `allowed_files`. +- `runner` executes experiments, tests, builds, or validation and records outputs. +- `reviewer` independently reviews quality, evidence, claims, safety, closure, or statistics. + +Legacy roles are normalized when read. New records store only canonical role +and profile names. `evidence-curator` is no longer dispatchable; deterministic +evidence commands organize and validate references before `reviewer:evidence` +judges their quality. Every task card should point at `.trellis/tasks//hermes/worker_records.jsonl` and should carry both `allowed_files` and `forbidden_files`. RecordBus records @@ -68,11 +78,12 @@ store task-specific evidence, claims, approvals, worker logs, or review results. - The local service queue goes to `service_queue.jsonl`; it supports enqueue, status, cancel, and retry records without a daemon. - Worker validation enforces one unfinished active writer per worktree. Active - writers are `coder` and `runner`; checker roles such as `reviewer`, - `evaluator`, and `literature` do not count as active writers. + writers are `coder` and `runner`; `planner`, `researcher`, and `reviewer` do + not count as active writers. - A `coder` result whose `status` or `handoff` enters `review` or `claim_ready` - must already have a related `runner` result and a related `reviewer` result - or checkpoint in `worker_records.jsonl`. + must already have a related `runner:test|build|validation` result and a + related `reviewer:quality|safety` result or checkpoint in + `worker_records.jsonl`. - `PreToolUse` hooks act as a role firewall and quality gate. They deny main agent mutation, execution, package/test commands, git mutation, and removal commands, then point to the appropriate subagent. Subagents still use the @@ -128,6 +139,9 @@ Hermes runtime scripts live under `.trellis/scripts/hermes/`. - Validate audit records with `python3 ./.trellis/scripts/hermes/validate.py --task --kind audit`. - Append plan change records with `python3 ./.trellis/scripts/hermes/record.py append --task --record-type plan_change --json ''`. - Validate plan change records with `python3 ./.trellis/scripts/hermes/validate.py --task --kind plan_change`. +- Collect a stable evidence index with `python3 ./.trellis/scripts/hermes/evidence.py collect --task `. +- Validate evidence paths, hashes, run links, and references with `python3 ./.trellis/scripts/hermes/evidence.py validate --task `. +- Print a compact evidence summary with `python3 ./.trellis/scripts/hermes/evidence.py summary --task `. - Aggregate run manifests with `python3 ./.trellis/scripts/hermes/report.py aggregate --task --output .trellis/tasks//hermes/aggregate.json`. - Append a compare record with `python3 ./.trellis/scripts/hermes/report.py compare --task --metric --baseline --new --threshold --direction higher_is_better`. - Generate a task report with `python3 ./.trellis/scripts/hermes/report.py report --task --question --method --data --metrics --limitations --risks `. @@ -145,7 +159,7 @@ Hermes runtime scripts live under `.trellis/scripts/hermes/`. Append a `task_card` record when dispatching a worker. ```json -{"type":"task_card","id":"tc-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","job_id":"job-YYYYMMDD-HHMMSS-slug","role":"coder|runner|evaluator|reviewer|literature|researcher|analyst","worktree_id":"main|worktree-name","status":"queued","allowed_files":["path/**"],"forbidden_files":["path/**"],"heartbeat_interval":"5m","timeout_at":"YYYY-MM-DDTHH:MM:SSZ","checkpoint":"not-started","resume_from":"task_card","record_uri":".trellis/tasks//hermes/worker_records.jsonl","evidence_refs":[],"risk_flags":[]} +{"type":"task_card","id":"tc-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","job_id":"job-YYYYMMDD-HHMMSS-slug","role":"reviewer","profile":"evidence","objective":"check current package evidence","worktree_id":"main|worktree-name","status":"queued","allowed_files":[".trellis/tasks//hermes/**"],"forbidden_files":["src/**",".env"],"heartbeat_interval":"5m","timeout_at":"YYYY-MM-DDTHH:MM:SSZ","checkpoint":"not-started","resume_from":"task_card","record_uri":".trellis/tasks//hermes/worker_records.jsonl","evidence_refs":[],"risk_flags":[]} ``` ### heartbeat @@ -207,12 +221,20 @@ experiment config changes. ### result -Append a `result` record when the worker completes the authorized task. +Agent output must first satisfy the Result Envelope. It requires `job_id`, +`status`, `conclusion`, `uncertainties`, `changed_files`, `evidence_refs`, and +`risk_flags`; optional bounded fields include `artifact_refs`, `run_refs`, +`next_action`, `review_judgment`, and `decision_requests`. ```json -{"type":"result","id":"rs-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","job_id":"job-YYYYMMDD-HHMMSS-slug","status":"done","summary":"what changed","changed_files":["path/file"],"evidence_refs":["ev-..."],"risk_flags":[],"handoff":"structured next step or review request"} +{"job_id":"job-YYYYMMDD-HHMMSS-slug","status":"success","conclusion":"bounded conclusion","uncertainties":[],"changed_files":["path/file"],"evidence_refs":[],"artifact_refs":[],"run_refs":[],"risk_flags":[],"next_action":"review current package"} ``` +The firewall then appends a compatible RecordBus `result`. Runner success must +use existing `run_refs` and cannot accept `evidence_refs`. Evidence and claim +reviewers return only proposed judgments; only existing ledgers can support +facts, and only an external `human/root` record can approve a claim. + ### stalled Append a `stalled` record when a timeout checker detects a missed heartbeat or 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 40ded10..034fc43 100644 --- a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md +++ b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md @@ -11,11 +11,16 @@ waits for human/root approval instead of inventing a local decision. ## Dispatch -Before starting a worker, append a `task_card` record through RecordBus. The -task card should define: +Before starting a worker, create and validate one dispatch with +`python3 ./.trellis/scripts/hermes/dispatch.py create`. The command writes the +dispatch file and its RecordBus `task_card`. Claude Agent calls receive only +the `job_id`; Codex strict work runs through the same CLI. + +The validated dispatch defines: - `job_id` - `role` +- `profile` (optional; the role default is stored when omitted) - `worktree_id` - `allowed_files` - `forbidden_files` @@ -24,9 +29,20 @@ task card should define: - `checkpoint` - `resume_from` - `record_uri` +- `hermes_revision` +- `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. -The `task_card` is the contract for a single worker. If there is no task card, -there is no accepted worker result. +Explicit task refs and task context pins take priority. The dispatch then adds +only the project background, research plan, or constraints that match the +worker's role/profile and fit within the three-ref limit. For example, a coder +gets project constraints, while an evidence reviewer gets the research plan and +constraints. Add a task-specific spec explicitly when it is more important than +an optional project document. The worker must not expand its own authority. If the task needs more scope, it records a risk or asks for a new task card. @@ -43,10 +59,10 @@ it should do independent supervision work: - inspect non-overlapping files or tests The main agent should not pull long logs, long diffs, or raw exploration history -into its own context. It should read structured records and evidence indexes. -When dispatching `reviewer` or `evaluator`, give them the task card, evidence -refs, changed-file summary, and the exact check to perform. Do not carry over -the coder or runner long conversation into a checker turn. +into its own context. Raw Agent/Codex output stays under the gitignored +`.trellis/.runtime/hermes-traces/` directory. Every canonical worker receives +only the validated dispatch body and at most three direct references. Do not +carry over the coder or runner conversation or preload full task history. ## Heartbeat And Timeout @@ -90,14 +106,15 @@ Use maker/checker separation: By default, one worktree has one active writer. `validate.py --kind worker` fails when multiple unfinished `coder` or `runner` task cards share the same -`worktree_id`. Read-only checker roles such as `reviewer`, `evaluator`, and -`literature` can run in parallel. If parallel writes are needed, use separate +`worktree_id`. Planning, research, and review roles can run independently when +their task-card write paths do not overlap. If parallel writes are needed, use separate worktrees or an explicit write lease. Do not keep two active writers in the same worktree. A coder result cannot hand off to `review` or `claim_ready` until the worker -records already contain a related `runner` result and a related `reviewer` -result or checkpoint. Checker records can support the handoff, but they do not +records already contain a related `runner:test|build|validation` result and a +related `reviewer:quality|safety` result or checkpoint. Evidence, claim, or +closure review cannot substitute for code-quality review. Checker records do not replace `human/root` approval when HumanGate is required. ## Acceptance @@ -111,10 +128,27 @@ Accept a worker result only when: - no forbidden file was changed - risks are recorded instead of hidden in prose - the result is still inside the task card boundary and below `HumanGate` +- `uncertainties` is present and the conclusion is at most 1200 characters +- it contains no complete diff, log stream, search process, secret, or absolute user path Reject a worker result with a `rejection` record when these conditions are not met. -Use `python3 ./.trellis/scripts/hermes/validate.py --task --kind worker` -and `python3 ./.trellis/scripts/hermes/guard.py --task --job-id ---changed-files ` before accepting worker output. +Use `python3 ./.trellis/scripts/hermes/dispatch.py apply --task +--job-id --result ` before the existing RecordBus validation. The +firewall stores raw output locally, writes only the sanitized result into the +task, and mechanically updates `next_action`. Then use `validate.py --kind +worker` and `guard.py --changed-files` before accepting worker output. + +## Completion And Release + +One dispatch is one bounded piece of work, not a reusable conversation. Once a +result has been accepted or recorded as failed, blocked, stale, interrupted, or +capacity-blocked, close that subagent immediately. Do not leave it idle, give it +another work package, or reuse its chat context. The next piece of work must use +a new dispatch and a new subagent with its own current revision and minimal +context. + +If the result is invalid, request only the permitted rewrite. After the rewrite +limit, record the invalid result, block the relevant job or work package, and +close the subagent. diff --git a/packages/cli/src/templates/trellis/hermes/roles/base.md b/packages/cli/src/templates/trellis/hermes/roles/base.md new file mode 100644 index 0000000..ad6f6ae --- /dev/null +++ b/packages/cli/src/templates/trellis/hermes/roles/base.md @@ -0,0 +1,10 @@ +# Hermes Base Contract + +- Read the Task Capsule before any larger task artifact. +- Treat `task.json` and append-only Hermes records as state; chat statements do not change state. +- Stay inside task-card file and command permissions. +- Load only the current assignment and at most three direct references by default. +- Cite concrete records, artifacts, commands, or sources in the required output. +- Stop and report a blocker for high-risk research changes or missing human approval. +- Never turn one run into a durable claim or project rule without the required review. +- Keep the result compact and append records instead of rewriting history. diff --git a/packages/cli/src/templates/trellis/hermes/roles/claim-reviewer.md b/packages/cli/src/templates/trellis/hermes/roles/claim-reviewer.md deleted file mode 100644 index 39879f2..0000000 --- a/packages/cli/src/templates/trellis/hermes/roles/claim-reviewer.md +++ /dev/null @@ -1,25 +0,0 @@ -# Hermes Claim Reviewer Role - -The claim reviewer checks whether claims are supported by evidence and ready for human approval. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. - -## Responsibilities - -- Read `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Check that each claim links to evidence records and states its scope, limits, and uncertainty. -- Verify that `planning -> running -> review -> claim_ready` records exist before recommending approval. -- Report missing evidence, unsupported wording, or state-machine violations. - -## Can - -- Review claims against the recorded evidence and state path. -- Append checker notes or rejection records that point to missing support. -- Tell the main session when `HumanGate` is the next required step. - -## Must not - -- Append or simulate a human approval record. -- Approve a claim on behalf of the human/root authority. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Treat reviewer agreement as approval. -- Move a task from `claim_ready` to `approved` without a human approval record. -- Cross `HumanGate` without the human/root approval record. diff --git a/packages/cli/src/templates/trellis/hermes/roles/coder.md b/packages/cli/src/templates/trellis/hermes/roles/coder.md index 2220cc0..06329af 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/coder.md +++ b/packages/cli/src/templates/trellis/hermes/roles/coder.md @@ -1,24 +1,34 @@ # Hermes Coder Role -The coder changes source files inside an explicit task boundary. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. +## Role purpose -## Responsibilities +Modify code, tests, or configuration inside an explicit task-card boundary. -- Read the task card, `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Keep edits inside `allowed_files` and avoid every `forbidden_files` path. -- Keep one active writer per worktree and record checkpoints, results, and risks in worker records. -- Run the requested code changes and targeted verification for the task. +## Allowed actions -## Can +- Edit `allowed_files` while avoiding every `forbidden_files` path. +- Add focused tests and run targeted implementation checks. +- Append checkpoints, implementation results, risks, and repair notes. -- Edit code inside the assigned `allowed_files`. -- Run targeted tests or build steps in the assigned worktree. -- Ask for a new task card when the scope needs to expand. +## Forbidden actions -## Must not +- Expand scope without a new or amended task card. +- Declare experimental evidence credible, approve a claim, or close the task. +- Rewrite append-only records or work as a second active writer in one worktree. -- Edit any `forbidden_files` path. -- Work outside the current `allowed_files`. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Treat chat-only agreement as acceptance. -- Cross `HumanGate` by assuming code review replaces human approval. +## Required output + +Changed files, the implemented outcome, focused verification, risks, and the requested handoff. + +## Available profiles + +- `implementation`: implement the assigned observable outcome. +- `tests`: add or repair focused automated coverage. +- `configuration`: change bounded configuration and synchronized templates. +- `repair`: fix only assigned defects or audit gaps. + +Default profile: `implementation`. + +## Completion conditions + +The assigned implementation is recorded and ready for an independent runner and reviewer; it is not yet task closure. diff --git a/packages/cli/src/templates/trellis/hermes/roles/evaluator.md b/packages/cli/src/templates/trellis/hermes/roles/evaluator.md deleted file mode 100644 index 279c127..0000000 --- a/packages/cli/src/templates/trellis/hermes/roles/evaluator.md +++ /dev/null @@ -1,27 +0,0 @@ -# Hermes Evaluator Role - -The evaluator checks results against the requested method and evidence standard. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. - -## Responsibilities - -- Read the task card, `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Compare outputs, measurements, and evidence against the requested acceptance criteria. -- Treat metric definitions, split definitions, and baseline values as fixed inputs unless a human/root approval explicitly changes them. -- Record whether the result is supported, incomplete, or needs another checkpoint. -- Keep the evaluation traceable to concrete files, commands, outputs, or measurements. - -## Can - -- Judge whether the observed result matches the task card. -- Append checkpoints, results, or risks for the active worker. -- Ask for `HumanGate` when the evidence is insufficient for a local decision. - -## Must not - -- Edit source files. -- Must not modify source files, metrics, split, or baseline. -- Must not modify metrics, split, or baseline definitions to make a result pass. -- Approve on behalf of the human/root authority. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Reuse coder or runner long-context chat as if it were evidence. -- Cross `HumanGate` by treating evaluator confidence as approval. diff --git a/packages/cli/src/templates/trellis/hermes/roles/evidence-curator.md b/packages/cli/src/templates/trellis/hermes/roles/evidence-curator.md deleted file mode 100644 index 7f99109..0000000 --- a/packages/cli/src/templates/trellis/hermes/roles/evidence-curator.md +++ /dev/null @@ -1,25 +0,0 @@ -# Hermes Evidence Curator Role - -The evidence curator turns observations into traceable append-only evidence records. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. - -## Responsibilities - -- Read `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, role guidance, and existing task records under `.trellis/tasks//hermes/`. -- Verify every evidence item has a stable source path, command, output artifact, citation, or measurement reference. -- Append evidence records with unique ids, timestamps, source references, and known limitations. -- Add correction records instead of editing old records when evidence changes. - -## Can - -- Curate evidence indexes and source notes for the active task. -- Append worker records that point to traceable evidence. -- Ask for `HumanGate` when evidence does not justify a local conclusion. - -## Must not - -- Invent evidence ids or references that do not exist. -- Store evidence only in the chat transcript. -- Modify claim or approval records to force consistency. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Decide that a claim is approved. -- Cross `HumanGate` by simulating human approval. diff --git a/packages/cli/src/templates/trellis/hermes/roles/literature.md b/packages/cli/src/templates/trellis/hermes/roles/literature.md deleted file mode 100644 index 047cc41..0000000 --- a/packages/cli/src/templates/trellis/hermes/roles/literature.md +++ /dev/null @@ -1,24 +0,0 @@ -# Hermes Literature Role - -The literature role gathers outside sources for a task. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. - -## Responsibilities - -- Read the task card, `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Find papers, citations, and source material that support the task. -- Record which sources matter, why they matter, and where the limits are. -- Keep the source trail traceable to concrete citations or documents. - -## Can - -- Search and summarize literature for the active task. -- Append evidence notes and source references to worker records. -- Flag when a task needs `HumanGate` because the source trail is incomplete. - -## Must not - -- Treat chat-only summaries as sources. -- Invent citations or source ids. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Approve the task or its claims. -- Cross `HumanGate` by presenting literature notes as approval. diff --git a/packages/cli/src/templates/trellis/hermes/roles/planner.md b/packages/cli/src/templates/trellis/hermes/roles/planner.md new file mode 100644 index 0000000..5338c9c --- /dev/null +++ b/packages/cli/src/templates/trellis/hermes/roles/planner.md @@ -0,0 +1,34 @@ +# Hermes Planner Role + +## Role purpose + +Design bounded research or engineering work and propose plans, methods, diagnostics, and amendments. + +## Allowed actions + +- Define intent, scope, completion criteria, work packages, methods, and testable causes. +- Propose plan amendments and bounded repair actions. +- Write planning or analysis records allowed by the task card. + +## Forbidden actions + +- Modify implementation code or mark packages done. +- Approve high-risk research changes, claims, or task closure. +- Replace recorded state with a natural-language decision. + +## Required output + +A bounded plan or analysis with assumptions, observable completion criteria, blockers, and next action. + +## Available profiles + +- `research_design`: question, hypothesis, experiment, evidence standard, limitations, claim boundary. +- `task_planning`: intent, scope, definition of done, 1-4 outcome-based work packages. +- `root_cause`: symptoms, testable causes, minimum diagnostics, repair boundary. +- `method_selection`: candidates, constraints, tradeoffs, verification criteria, risk. + +Default profile: `task_planning`. + +## Completion conditions + +The requested decision support is recorded, bounded, and ready for the main agent or closure command to apply. diff --git a/packages/cli/src/templates/trellis/hermes/roles/researcher.md b/packages/cli/src/templates/trellis/hermes/roles/researcher.md new file mode 100644 index 0000000..f1703d9 --- /dev/null +++ b/packages/cli/src/templates/trellis/hermes/roles/researcher.md @@ -0,0 +1,34 @@ +# Hermes Researcher Role + +## Role purpose + +Find and summarize traceable information from the repository, literature, official documentation, or prior art. + +## Allowed actions + +- Search and read sources relevant to the task card. +- Record concise findings, source references, and limitations. +- Flag missing or contradictory information. + +## Forbidden actions + +- Modify code, experiment results, claims, approvals, package state, or closure state. +- Invent citations, source identifiers, or conclusions not supported by a source. +- Load unrelated history or every project specification by default. + +## Required output + +A compact source trail with findings, direct references, applicability limits, and unresolved questions. + +## Available profiles + +- `literature`: papers, DOI, authors, venue, evidence type, citation limits. +- `codebase`: files, call paths, tests, and repository conventions. +- `external_docs`: official APIs, version differences, and external constraints. +- `prior_art`: comparable projects, existing mechanisms, and applicability boundaries. + +Default profile: `codebase`. + +## Completion conditions + +The requested information is traceable and sufficient for the caller's next decision, or the missing evidence is explicit. diff --git a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md index 2ee3b95..0b7eea4 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md +++ b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md @@ -1,26 +1,36 @@ # Hermes Reviewer Role -The reviewer checks diffs, records, and task-card scope before handoff. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. +## Role purpose -## Responsibilities +Independently review quality, evidence, claims, safety, statistics, or closure against recorded criteria. -- Read the task card, `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- The reviewer should only read the current diff, records, evidence, task artifacts, and task instructions needed for the review. -- Review changed files, evidence refs, and worker records against the task scope. -- Report missing evidence, scope drift, or record problems. -- Keep the review focused on the current diff and not on chat history. +## Allowed actions -## Can +- Read the current diff, task card, relevant records, artifacts, and at most the references needed for the review. +- Append review findings, rejection records, and compact gap lists. +- Recommend approval prerequisites while preserving `HumanGate`. -- Approve or reject a worker handoff recommendation for the main session. -- Append review notes, checkpoints, and rejection records. -- Point out when `HumanGate` remains open. +## Forbidden actions -## Must not +- Edit source code, original results, metrics, datasets, splits, or baselines. +- Forge evidence, overwrite records, or create human approval. +- Use inherited coder or runner conversation as evidence. -- Edit source files. -- Approve claims or results on behalf of the human/root authority. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Inherit coder or runner long conversation as the basis for judgment. -- Must not inherit coder long conversation or runner long conversation as review context. -- Cross `HumanGate` by replacing human approval with reviewer agreement. +## Required output + +Verdict, blocking findings, evidence references, limitations, and next actions. + +## Available profiles + +- `quality`: correctness, maintenance, tests, regressions, scope. +- `evidence`: done conditions, artifacts, hashes, manifests, missing evidence. +- `claim`: wording, evidence, scope, limitations, approval prerequisites. +- `safety`: permissions, sensitive data, destructive actions, security risk. +- `closure`: completion criteria, package disposition, blockers, repair count, close gate. +- `statistics`: sample count, variance, intervals, seeds, splits, effect size, fairness. + +Default profile: `quality`. + +## Completion conditions + +The independent verdict and all blocking gaps are recorded without changing the reviewed source of truth. diff --git a/packages/cli/src/templates/trellis/hermes/roles/runner.md b/packages/cli/src/templates/trellis/hermes/roles/runner.md index 2ca8ce4..7615dc5 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/runner.md +++ b/packages/cli/src/templates/trellis/hermes/roles/runner.md @@ -1,25 +1,34 @@ # Hermes Runner Role -The runner executes commands, tests, and measurements for one task. This role writes to `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. +## Role purpose -## Responsibilities +Execute tests, experiments, builds, and validation with reproducible run records. -- Read the task card, `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Run the requested commands inside the assigned worktree. -- Capture checkpoints, results, and risks in worker records. -- Keep outputs traceable to the exact command or measurement that produced them. +## Allowed actions -## Can +- Run commands authorized by the task card. +- Register run manifests, artifact paths, checkpoints, results, and risks. +- Report environment failures and request a coder when implementation changes are needed. -- Run tests, builds, benchmarks, or data checks that the task card authorizes. -- Append heartbeat, checkpoint, result, and risk records. -- Report when a new task card is needed before continuing. +## Forbidden actions -## Must not +- Modify core implementation code or silently repair a failed run. +- Change metrics, datasets, splits, baselines, or original result values. +- Treat command success as evidence approval, claim approval, or task closure. -- Edit source files unless the task card explicitly authorizes that scope. -- Touch any `forbidden_files` path. -- Work outside `allowed_files`. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Convert command output into approval. -- Cross `HumanGate` by claiming the command result is approved. +## Required output + +Exact commands, environment summary, exit status, artifact references, and limitations. + +## Available profiles + +- `experiment`: execute the declared experiment and register its outputs. +- `test`: execute focused or full automated tests. +- `build`: build the requested packages or distribution. +- `validation`: run bounded validation and environment checks. + +Default profile: `validation`. + +## Completion conditions + +The requested run is reproducible and recorded, or its blocker is explicit; implementation changes return to a coder. diff --git a/packages/cli/src/templates/trellis/hermes/roles/scientist.md b/packages/cli/src/templates/trellis/hermes/roles/scientist.md deleted file mode 100644 index 4ae426e..0000000 --- a/packages/cli/src/templates/trellis/hermes/roles/scientist.md +++ /dev/null @@ -1,26 +0,0 @@ -# Hermes Scientist Role - -The scientist frames the research question, method, evidence standard, and claim scope for one Trellis task. This role works through `.trellis/tasks//hermes/worker_records.jsonl` and stops at `HumanGate`. - -## Responsibilities - -- Read `.trellis/hermes/config.yaml`, `.trellis/hermes/state_machine.yaml`, and task records under `.trellis/tasks//hermes/`. -- Keep the task research brief, method, expected evidence, and acceptance criteria aligned. -- Append evidence or claim draft records only when they cite concrete files, commands, outputs, papers, or measurements. -- Write task cards with `allowed_files` and `forbidden_files` when coordinating follow-up work. -- Mark uncertainty and scope limits before proposing `claim_ready`. - -## Can - -- Draft research questions, evidence plans, and claim scopes. -- Append traceable worker records for evidence-backed observations. -- Flag when the work needs `HumanGate` instead of a local decision. - -## Must not - -- Treat chat-only summaries as evidence. -- Approve claims or create human approval records. -- Rewrite, reorder, truncate, or delete append-only Hermes records. -- Skip the `review` state before proposing a claim. -- Present a claim outside the task as approved without a matching human approval record. -- Cross `HumanGate` by marking `approved` without human approval. 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 new file mode 100644 index 0000000..2ef9195 --- /dev/null +++ b/packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Hermes Result Envelope", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "job_id", + "task_revision", + "role", + "profile", + "status", + "conclusion", + "uncertainties", + "changed_files", + "evidence_refs", + "artifact_refs", + "verification", + "risks", + "next_action" + ], + "properties": { + "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}$" }, + "task_revision": { "type": "integer", "minimum": 0 }, + "role": { "type": "string", "enum": ["planner", "researcher", "coder", "runner", "reviewer"] }, + "profile": { "type": "string" }, + "status": { "type": "string", "enum": ["success", "failure", "blocked"] }, + "conclusion": { "type": "string", "maxLength": 1200 }, + "uncertainties": { "type": "array", "items": { "type": "string" } }, + "changed_files": { "type": "array", "items": { "type": "string" } }, + "evidence_refs": { "type": "array", "items": { "type": "string" } }, + "artifact_refs": { "type": "array", "items": { "type": "string" } }, + "run_refs": { "type": "array", "items": { "type": "string" } }, + "risk_flags": { "type": "array", "items": { "type": "string" } }, + "verification": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { "type": "string" }, + "run_refs": { "type": "array", "items": { "type": "string" } }, + "commands": { "type": "array", "items": { "type": "string" } } + } + }, + "risks": { "type": "array", "items": { "type": "string" } }, + "next_action": { "type": "string", "maxLength": 300 }, + "review_judgment": { "type": ["object", "null"] }, + "decision_requests": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/packages/cli/src/templates/trellis/index.ts b/packages/cli/src/templates/trellis/index.ts index ac2aaec..7fa11ee 100644 --- a/packages/cli/src/templates/trellis/index.ts +++ b/packages/cli/src/templates/trellis/index.ts @@ -81,6 +81,9 @@ export const commonTasks = readTemplate("scripts/common/tasks.py"); export const commonTaskContext = readTemplate("scripts/common/task_context.py"); export const commonTaskStore = readTemplate("scripts/common/task_store.py"); export const commonClosure = readTemplate("scripts/common/closure.py"); +export const commonRoles = readTemplate("scripts/common/roles.py"); +export const commonDispatch = readTemplate("scripts/common/dispatch.py"); +export const commonFirewall = readTemplate("scripts/common/firewall.py"); export const commonSessionContext = readTemplate( "scripts/common/session_context.py", ); @@ -114,6 +117,14 @@ export const closureScript = readTemplate("scripts/closure.py"); export const workflowMdTemplate = readTemplate("workflow.md"); export const configYamlTemplate = readTemplate("config.yaml"); export const gitignoreTemplate = readTemplate("gitignore.txt"); +export const projectBackgroundTemplate = readTemplate("project/BACKGROUND.md"); +export const projectResearchPlanTemplate = readTemplate( + "project/RESEARCH_PLAN.md", +); +export const projectConstraintsTemplate = readTemplate( + "project/CONSTRAINTS.md", +); +export const projectReadmeTemplate = readTemplate("project/README.md"); /** * Get all script templates as a map of relative path to content @@ -142,6 +153,9 @@ export function getAllScripts(): Map { scripts.set("common/task_context.py", commonTaskContext); scripts.set("common/task_store.py", commonTaskStore); scripts.set("common/closure.py", commonClosure); + scripts.set("common/roles.py", commonRoles); + scripts.set("common/dispatch.py", commonDispatch); + scripts.set("common/firewall.py", commonFirewall); scripts.set("common/session_context.py", commonSessionContext); scripts.set("common/packages_context.py", commonPackagesContext); scripts.set("common/workflow_phase.py", commonWorkflowPhase); diff --git a/packages/cli/src/templates/trellis/project/BACKGROUND.md b/packages/cli/src/templates/trellis/project/BACKGROUND.md new file mode 100644 index 0000000..d628f3d --- /dev/null +++ b/packages/cli/src/templates/trellis/project/BACKGROUND.md @@ -0,0 +1,17 @@ +# Project Background + +## Origin And Current State + +- Repository source and ownership: +- Domain and users: +- Current implementation or research stage: + +## Objective + +- Project-level objective: +- Current problem being addressed: + +## Existing Assets + +- Important code, data, models, or external systems: +- Existing reports or evidence: diff --git a/packages/cli/src/templates/trellis/project/CONSTRAINTS.md b/packages/cli/src/templates/trellis/project/CONSTRAINTS.md new file mode 100644 index 0000000..1f0afd6 --- /dev/null +++ b/packages/cli/src/templates/trellis/project/CONSTRAINTS.md @@ -0,0 +1,17 @@ +# Project Constraints + +## Fixed Boundaries + +- In-scope and out-of-scope areas: +- Compatibility or API requirements: +- Files, services, data, or platforms that must not change: + +## Research And Operational Constraints + +- Dataset, split, metric, baseline, or reproducibility requirements: +- Resource, runtime, security, privacy, or approval constraints: + +## Decision Rules + +- Changes that require a focused grill or human approval: +- Conditions that block execution or closure: diff --git a/packages/cli/src/templates/trellis/project/README.md b/packages/cli/src/templates/trellis/project/README.md new file mode 100644 index 0000000..dec1479 --- /dev/null +++ b/packages/cli/src/templates/trellis/project/README.md @@ -0,0 +1,20 @@ +# Project Context + +These files are the project-level inputs for the main agent. Session startup +lists their names and paths without injecting their contents. The main agent +reads only the files relevant to the incoming request before deciding whether +the request is in scope, needs discussion, or can be split into work packages. + +- `BACKGROUND.md`: project origin, domain, current state, and intended value. +- `RESEARCH_PLAN.md`: research question, current approach, evidence standard, + and known limitations. +- `CONSTRAINTS.md`: fixed boundaries, compatibility requirements, resources, + data restrictions, and prohibited changes. +- `PROJECT_INDEX.md`: a deterministic, root-level reading map generated at + initialization. It lists files and directories but does not summarize or + infer project facts. + +For an existing project, replace the placeholders with facts from its docs, +code, records, and maintainers. For a new repository, fill only what is known; +do not invent a research plan or constraints. These documents are project +context, not task plans and not automatic subagent context. diff --git a/packages/cli/src/templates/trellis/project/RESEARCH_PLAN.md b/packages/cli/src/templates/trellis/project/RESEARCH_PLAN.md new file mode 100644 index 0000000..7fe8e7c --- /dev/null +++ b/packages/cli/src/templates/trellis/project/RESEARCH_PLAN.md @@ -0,0 +1,16 @@ +# Research Plan + +## Research Question + +- Question or engineering objective: +- Current hypothesis or intended behavior: + +## Current Approach + +- Current model, method, or system design: +- Data, evaluation, and comparison approach: + +## Evidence And Limits + +- Required evidence before a conclusion: +- Known limitations and claim boundaries: diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index c71e0bd..b134ca8 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -10,6 +10,7 @@ from common.closure import ( CLOSURE_MODES, + RESEARCH_ROUTES, ClosureError, actor_name, amend_plan, @@ -21,10 +22,12 @@ complete_package, format_audit_yaml, is_closure_task, + mark_grill_complete, package_by_id, plan_closure, repair_closure, resolve_closure_task, + set_research_route, start_package, validate_and_ready, write_handoff, @@ -64,7 +67,20 @@ def parser() -> argparse.ArgumentParser: plan.add_argument("--out-of-scope", action="append", default=None) plan.add_argument("--done-when", action="append", default=None) plan.add_argument("--package", action="append", type=package_spec, default=None) + plan.add_argument("--context-pin", action="append", default=None) plan.add_argument("--mode", choices=sorted(CLOSURE_MODES)) + plan.add_argument("--route", choices=sorted(RESEARCH_ROUTES)) + plan.add_argument("--research-change", action="append", default=None) + + route = subparsers.add_parser("route", help="Mark the planning-time research route") + add_common(route) + route.add_argument("--route", choices=sorted(RESEARCH_ROUTES)) + route.add_argument("--research-change", action="append", default=None) + route.add_argument("--clear-research-changes", action="store_true") + + grill = subparsers.add_parser("grill", help="Record a completed exploration grill") + add_common(grill) + grill.add_argument("--complete", action="store_true") validate = subparsers.add_parser("validate", help="Validate plan and enter ready") add_common(validate) @@ -146,12 +162,36 @@ def main(argv: list[str] | None = None) -> int: out_of_scope=args.out_of_scope, definition_of_done=args.done_when, packages=args.package, + context_pins=args.context_pin, mode=args.mode, + research_route_name=args.route, + research_change_fields=args.research_change, ) print(f"planned {len(data.get('work_packages') or [])} work package(s)") for warning in warnings: print(f"warning: {warning}", file=sys.stderr) return 0 + if args.command == "route": + result = set_research_route( + task_dir, + data, + route=args.route, + research_change_fields=args.research_change, + clear_research_changes=args.clear_research_changes, + actor=actor, + ) + print( + "route=" + result["research_route"] + + "; research_changes=" + + (", ".join(result["research_change_fields"]) or "none") + ) + return 0 + if args.command == "grill": + if not args.complete: + raise ClosureError("use grill --complete after the exploration grill") + mark_grill_complete(task_dir, data, actor=actor) + print("exploration grill recorded") + return 0 if args.command == "validate": errors, warnings = validate_and_ready(task_dir, data, actor=actor) for warning in warnings: diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index c1a0c21..fc4ba24 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -6,17 +6,43 @@ import re import subprocess import uuid +from copy import deepcopy from datetime import datetime, timezone from pathlib import Path from typing import Any -from .io import read_json, write_json +from .firewall import closure_mode_gate +from .io import file_lock, read_json, write_json_atomic, write_text_atomic from .paths import FILE_TASK_JSON, get_current_task, get_developer, get_repo_root from .task_utils import resolve_task_dir CLOSURE_MODES = {"lean", "standard", "publication"} HERMES_PHASES = {"planning", "ready", "running", "review", "blocked", "closed"} +VALIDATION_LEVELS = {"targeted", "basic", "standard"} +CONSTRAINT_FIELDS = {"excluded_platforms", "excluded_paths", "validation_level"} +RESEARCH_ROUTES = {"delivery", "execution", "exploration"} +RESEARCH_CHANGE_FIELDS = { + "hypothesis", + "model_architecture", + "dataset", + "split", + "preprocessing", + "objective_loss", + "metric_definition", + "baseline", + "claim_scope", +} +RESEARCH_CHANGE_ALIASES = { + "objective": "objective_loss", + "loss": "objective_loss", + "objective/loss": "objective_loss", +} +RESEARCH_ROUTE_LABELS = { + "delivery": "delivery", + "execution": "execution", + "exploration": "exploration", +} PACKAGE_STATUSES = { "pending", "ready", @@ -31,8 +57,13 @@ HIGH_RISK_FIELDS = { "closure_mode", "hypothesis", + "model_architecture", "dataset", "split", + "preprocessing", + "objective", + "loss", + "objective_loss", "metric_definition", "baseline", "claim_scope", @@ -46,6 +77,7 @@ "in_scope", "out_of_scope", "definition_of_done", + "context_pins", "max_repair_count", } AMENDABLE_PACKAGE_FIELDS = { @@ -61,6 +93,8 @@ "package_completed", "package_blocked", "plan_amended", + "research_route_updated", + "grill_completed", "repair_started", "task_closed", } @@ -80,6 +114,22 @@ r"^(?:测试|单元测试|集成测试|回归测试|构建|类型检查|代码检查)(?:全部)?通过$", re.IGNORECASE, ) +IMPLEMENT_SECTION_RE = re.compile( + r"^(?:work\s+packages?|implementation\s+units?|工作包|实施单元)\s*$", + re.IGNORECASE, +) +IMPLEMENT_UNIT_RE = re.compile( + r"^(?:(?:work\s+package|package|implementation\s+unit|WP)\s*#?\d*|工作包\s*\d*|实施单元\s*\d*)\s*[::-]\s*(.+)$", + re.IGNORECASE, +) +IMPLEMENT_OUTCOME_RE = re.compile( + r"^(?:[-*]\s*)?(?:outcome|result|deliverable|产出|结果)\s*[::]\s*(.+)$", + re.IGNORECASE, +) +IMPLEMENT_DONE_RE = re.compile( + r"^(?:[-*]\s*)?(?:done\s+when|acceptance|verification|完成条件|验收|验证)\s*[::]\s*(.*)$", + re.IGNORECASE, +) class ClosureError(ValueError): @@ -111,12 +161,23 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "in_scope": [], "out_of_scope": [], "definition_of_done": [], + "context_pins": [], + "research_route": "delivery", + "research_change_fields": [], + "grill_completed": False, + "constraints": { + "excluded_platforms": [], + "excluded_paths": [], + "validation_level": "targeted", + }, "work_packages": [], "current_work_package": None, "next_action": None, "blockers": [], "repair_count": 0, "max_repair_count": default_repair_limit(mode), + "hermes_revision": 0, + "confirmed_dispatches": [], } @@ -127,7 +188,13 @@ def ensure_closure_defaults(data: dict[str, Any], mode: str | None = None) -> No defaults = closure_defaults(selected_mode) for key, value in defaults.items(): if key not in data: - data[key] = list(value) if isinstance(value, list) else value + data[key] = deepcopy(value) + constraints = data.get("constraints") + if not isinstance(constraints, dict): + data["constraints"] = deepcopy(defaults["constraints"]) + else: + for key, value in defaults["constraints"].items(): + constraints.setdefault(key, deepcopy(value)) data["closure_mode"] = selected_mode if not isinstance(data.get("max_repair_count"), int): data["max_repair_count"] = default_repair_limit(selected_mode) @@ -151,8 +218,34 @@ def resolve_closure_task( return task_dir, data, root -def save_task(task_dir: Path, data: dict[str, Any]) -> None: - if not write_json(task_dir / FILE_TASK_JSON, data): +def task_state_lock(task_dir: Path): + return file_lock(task_dir / "hermes" / ".task-state.lock") + + +def save_task( + task_dir: Path, + data: dict[str, Any], + *, + lock_held: bool = False, +) -> None: + if not lock_held: + 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: + raise ClosureError( + f"stale task revision: expected {revision}, current {current_revision}" + ) + data["hermes_revision"] = revision + 1 + if not write_json_atomic(task_dir / FILE_TASK_JSON, data): + data["hermes_revision"] = revision raise ClosureError("failed to write task.json") @@ -167,6 +260,7 @@ def append_event( reason: str = "", evidence_refs: list[str] | None = None, extra: dict[str, Any] | None = None, + lock_held: bool = False, ) -> dict[str, Any]: if event_type not in EVENT_TYPES: raise ClosureError(f"unsupported closure event: {event_type}") @@ -183,10 +277,28 @@ def append_event( } if extra: record.update(extra) + if not lock_held: + with task_state_lock(task_dir): + return append_event( + task_dir, + event_type, + actor=actor, + old_state=old_state, + new_state=new_state, + package_id=package_id, + reason=reason, + evidence_refs=evidence_refs, + extra=extra, + lock_held=True, + ) path = task_dir / "hermes" / "task-events.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + try: + existing = path.read_text(encoding="utf-8") if path.exists() else "" + except OSError as exc: + raise ClosureError("failed to read task event log") from exc + line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" + if not write_text_atomic(path, existing + line): + raise ClosureError("failed to write task event log") return record @@ -228,6 +340,67 @@ def is_validation_only(value: str) -> bool: return bool(VALIDATION_ONLY_RE.search(value.strip())) +def closure_constraints(data: dict[str, Any]) -> dict[str, Any]: + """Return compatible, bounded closure constraints without mutating a task.""" + raw = data.get("constraints") + constraints = raw if isinstance(raw, dict) else {} + platforms = [ + item.casefold() + for item in _string_list(constraints.get("excluded_platforms")) + ] + paths = _string_list(constraints.get("excluded_paths")) + level = constraints.get("validation_level") + return { + "excluded_platforms": _dedupe(platforms), + "excluded_paths": _dedupe(paths), + "validation_level": level if level in VALIDATION_LEVELS else "targeted", + } + + +def research_route(data: dict[str, Any]) -> str: + """Return the compatible route for a closure task without mutating it.""" + value = data.get("research_route") + return value if value in RESEARCH_ROUTES else "delivery" + + +def research_route_rule(data: dict[str, Any]) -> str: + route = research_route(data) + if route == "exploration": + return "Protocol or functional model change: record a completed grill before validate." + if route == "execution": + return "Frozen protocol: run seeds or pre-approved parameter search only." + return "No protocol change: equivalent refactors and performance work stay delivery." + + +def research_route_summary(data: dict[str, Any]) -> str: + route = research_route(data) + changes = _compact_list(_research_change_fields(data), 3) or "-" + grill = "complete" if data.get("grill_completed") is True else ( + "required" if route == "exploration" else "not required" + ) + return ( + f"Route: {RESEARCH_ROUTE_LABELS[route]} | " + f"Research changes: {changes} | Grill: {grill}" + ) + + +def _research_change_fields(data: dict[str, Any]) -> list[str]: + raw = data.get("research_change_fields") + if not isinstance(raw, list): + return [] + return [item for item in raw if isinstance(item, str) and item.strip()] + + +def _normalize_research_change_fields(values: list[str]) -> list[str]: + normalized = [RESEARCH_CHANGE_ALIASES.get(value.strip(), value.strip()) for value in values] + invalid = sorted({value for value in normalized if value not in RESEARCH_CHANGE_FIELDS}) + if invalid: + raise ClosureError( + "unknown research change field: " + ", ".join(invalid) + ) + return _dedupe(normalized) + + def _short_title(value: str, index: int) -> str: compact = re.sub(r"\s+", " ", value).strip().rstrip("。.") if not compact: @@ -240,35 +413,104 @@ def candidate_work_packages( definition_of_done: list[str], ) -> list[dict[str, Any]]: criteria = [item.strip() for item in definition_of_done if item.strip()] - outcomes = [ - item for item in criteria - if not is_microstep(item) and not is_validation_only(item) + outcome = intent.strip() or "Complete the task's observable result" + return [ + { + "id": "WP1", + "title": _short_title(outcome, 1), + "outcome": outcome, + "done_when": criteria or [outcome], + "evidence_required": [], + "depends_on": [], + "status": "pending", + "evidence_refs": [], + "blocker": None, + } ] - if not outcomes: - outcome = intent.strip() or "Complete the task's observable result" - outcomes = [outcome] + + +def parse_implement_work_packages( + implement_path: Path, + definition_of_done: list[str], +) -> list[dict[str, Any]]: + """Read explicit implementation units without treating every DoD item as a unit.""" + if not implement_path.is_file(): + return [] + try: + lines = implement_path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + + units: list[dict[str, Any]] = [] + section_level: int | None = None + current: dict[str, Any] | None = None + collecting_done = False + for line in lines: + heading = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) + if heading: + level = len(heading.group(1)) + text = heading.group(2).strip() + if IMPLEMENT_SECTION_RE.match(text): + section_level = level + current = None + collecting_done = False + continue + explicit = IMPLEMENT_UNIT_RE.match(text) + if explicit or (section_level is not None and level > section_level): + title = (explicit.group(1) if explicit else text).strip() + current = {"title": title, "outcome": title, "done_when": []} + units.append(current) + collecting_done = False + continue + if section_level is not None and level <= section_level: + section_level = None + current = None + collecting_done = False + continue + if current is None: + continue + outcome = IMPLEMENT_OUTCOME_RE.match(line.strip()) + if outcome: + current["outcome"] = outcome.group(1).strip() + collecting_done = False + continue + done = IMPLEMENT_DONE_RE.match(line.strip()) + if done: + value = done.group(1).strip() + if value: + current["done_when"].append(value) + collecting_done = True + continue + bullet = re.match(r"^\s*[-*]\s+(.+)$", line) + if collecting_done and bullet: + current["done_when"].append(bullet.group(1).strip()) + elif line.strip(): + collecting_done = False + packages: list[dict[str, Any]] = [] - for index, outcome in enumerate(outcomes, start=1): + for index, unit in enumerate(units, start=1): + outcome = str(unit["outcome"]).strip() + if not outcome: + continue packages.append( - { - "id": f"WP{index}", - "title": _short_title(outcome, index), - "outcome": outcome, - "done_when": [outcome], - "evidence_required": [], - "depends_on": [], - "status": "pending", - "evidence_refs": [], - "blocker": None, - } + normalize_package( + { + "id": f"WP{index}", + "title": _short_title(str(unit["title"]), index), + "outcome": outcome, + "done_when": _dedupe(unit["done_when"]) or [outcome], + }, + index, + ) ) - current_index = 0 - for criterion in criteria: - if criterion in outcomes: - current_index = outcomes.index(criterion) - continue - package = packages[current_index] - package["done_when"] = _dedupe([*package["done_when"], criterion]) + if packages: + known = " ".join( + f"{item['title']} {item['outcome']} {' '.join(item['done_when'])}" + for item in packages + ).casefold() + unmatched = [item for item in definition_of_done if item.casefold() not in known] + if unmatched: + packages[-1]["done_when"] = _dedupe([*packages[-1]["done_when"], *unmatched]) return packages @@ -295,7 +537,10 @@ def plan_closure( out_of_scope: list[str] | None = None, definition_of_done: list[str] | None = None, packages: list[dict[str, Any]] | None = None, + context_pins: list[str] | None = None, mode: str | None = None, + research_route_name: str | None = None, + research_change_fields: list[str] | None = None, ) -> list[str]: old_phase = str(data.get("hermes_phase") or "planning") if is_closure_task(data) and old_phase != "planning": @@ -311,17 +556,48 @@ def plan_closure( data["out_of_scope"] = _dedupe(out_of_scope) if definition_of_done is not None: data["definition_of_done"] = _dedupe(definition_of_done) + if context_pins is not None: + data["context_pins"] = _dedupe(context_pins) + normalized_research_changes: list[str] | None = None + if research_change_fields is not None: + normalized_research_changes = _normalize_research_change_fields( + research_change_fields + ) + if normalized_research_changes: + if research_route_name in {"delivery", "execution"}: + raise ClosureError( + "research protocol changes require the exploration route" + ) + if research_route_name is None: + research_route_name = "exploration" + if research_route_name is not None: + if research_route_name not in RESEARCH_ROUTES: + raise ClosureError("research route must be delivery, execution, or exploration") + data["research_route"] = research_route_name + data["grill_completed"] = False + if normalized_research_changes is not None: + data["research_change_fields"] = normalized_research_changes + data["grill_completed"] = False if not _string_list(data.get("definition_of_done")): data["definition_of_done"] = parse_prd_definition_of_done(task_dir / "prd.md") selected = packages + warnings: list[str] = [] if selected is None and not isinstance(data.get("work_packages"), list): selected = [] if selected is None and not data.get("work_packages"): - selected = candidate_work_packages( - str(data.get("intent") or ""), + selected = parse_implement_work_packages( + task_dir / "implement.md", _string_list(data.get("definition_of_done")), ) + if not selected: + selected = candidate_work_packages( + str(data.get("intent") or ""), + _string_list(data.get("definition_of_done")), + ) + warnings.append( + "No explicit work package or implementation unit found in implement.md; created one safe work package." + ) if selected is not None: data["work_packages"] = [normalize_package(item, i) for i, item in enumerate(selected, start=1)] @@ -335,9 +611,106 @@ def plan_closure( data["max_repair_count"] = default_repair_limit(str(data["closure_mode"])) save_task(task_dir, data) count = len(data.get("work_packages") or []) - return [ - f"{count} work packages exceed the lean default; consider multiple Trellis tasks." - ] if count >= 5 else [] + if count >= 5: + warnings.append( + f"{count} work packages exceed the lean default; consider multiple Trellis tasks." + ) + return warnings + + +def set_research_route( + task_dir: Path, + data: dict[str, Any], + *, + route: str | None, + research_change_fields: list[str] | None, + clear_research_changes: bool, + actor: str, +) -> dict[str, Any]: + """Record a planning-time research route without adding another workflow.""" + if route is None and research_change_fields is None and not clear_research_changes: + raise ClosureError("set --route, --research-change, or --clear-research-changes") + ensure_closure_defaults(data) + if data.get("hermes_phase") != "planning": + raise ClosureError("research route can only be marked during planning") + if route is not None and route not in RESEARCH_ROUTES: + raise ClosureError("research route must be delivery, execution, or exploration") + if research_change_fields is not None and clear_research_changes: + raise ClosureError("cannot combine --research-change with --clear-research-changes") + + before = { + "research_route": research_route(data), + "research_change_fields": _research_change_fields(data), + "grill_completed": data.get("grill_completed") is True, + } + normalized_research_changes: list[str] | None = None + if research_change_fields is not None: + normalized_research_changes = _normalize_research_change_fields( + research_change_fields + ) + next_route = route if route is not None else research_route(data) + next_fields = ( + normalized_research_changes + if normalized_research_changes is not None + else [] if clear_research_changes else _research_change_fields(data) + ) + if next_fields and next_route in {"delivery", "execution"}: + if route is not None: + raise ClosureError("research protocol changes require the exploration route") + next_route = "exploration" + + data["research_route"] = next_route + data["research_change_fields"] = next_fields + + changed = before["research_route"] != research_route(data) or ( + before["research_change_fields"] != _research_change_fields(data) + ) + if changed: + data["grill_completed"] = False + data["next_action"] = closure_next_action(data) + save_task(task_dir, data) + after = { + "research_route": research_route(data), + "research_change_fields": _research_change_fields(data), + "grill_completed": data.get("grill_completed") is True, + } + append_event( + task_dir, + "research_route_updated", + actor=actor, + old_state=json.dumps(before, ensure_ascii=False, separators=(",", ":")), + new_state=json.dumps(after, ensure_ascii=False, separators=(",", ":")), + reason="planning-time research route recorded", + ) + return after + + +def mark_grill_complete( + task_dir: Path, + data: dict[str, Any], + *, + actor: str, +) -> None: + ensure_closure_defaults(data) + if data.get("hermes_phase") != "planning": + raise ClosureError("grill completion can only be marked during planning") + if research_route(data) != "exploration": + raise ClosureError("grill completion is only required for exploration") + if not _research_change_fields(data): + raise ClosureError("exploration must record at least one research change field") + if data.get("grill_completed") is True: + return + data["grill_completed"] = True + data["next_action"] = closure_next_action(data) + save_task(task_dir, data) + append_event( + task_dir, + "grill_completed", + actor=actor, + old_state="pending", + new_state="complete", + reason="exploration grill completed", + ) def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: @@ -345,23 +718,19 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: warnings: list[str] = [] if data.get("closure_mode") not in CLOSURE_MODES: errors.append("closure_mode must be lean, standard, or publication") + errors.extend(_validate_research_route(data)) phase = data.get("hermes_phase") - task_status = data.get("status") closure_state = data.get("closure_state") if phase not in HERMES_PHASES: errors.append("hermes_phase is invalid") if closure_state not in {"open", "closed"}: errors.append("closure_state must be open or closed") - expected_status = { - "planning": "planning", - "ready": "planning", - "running": "in_progress", - "review": "in_progress", - "blocked": "in_progress", - "closed": "completed", - }.get(str(phase)) - if expected_status and task_status != expected_status: - errors.append(f"task status {task_status} conflicts with hermes_phase {phase}") + # hermes_phase is authoritative for closure tasks. The legacy status field + # remains an interoperability hint and must not send ready/running/review + # work back through the legacy planning workflow. + task_status = data.get("status") + if task_status is not None and not isinstance(task_status, str): + warnings.append("legacy task status is not a string; hermes_phase remains authoritative") if phase == "closed" and closure_state != "closed": errors.append("closed phase requires closure_state=closed") if phase != "closed" and closure_state == "closed": @@ -397,6 +766,9 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: 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: + errors.append("hermes_revision must be a non-negative integer") if not str(data.get("intent") or "").strip(): errors.append("intent is required") if not _string_list(data.get("definition_of_done")): @@ -404,6 +776,31 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: if _string_list(data.get("blockers")): errors.append("unresolved blockers must be resolved before validation") + pins = _string_list(data.get("context_pins")) + if isinstance(data.get("context_pins"), list) and len(pins) != len(data.get("context_pins")): + errors.append("context_pins must be an array of non-empty strings") + if len(pins) > 3: + errors.append("context_pins supports at most 3 entries") + raw_constraints = data.get("constraints") + if raw_constraints is not None and not isinstance(raw_constraints, dict): + errors.append("constraints must be an object") + elif isinstance(raw_constraints, dict): + unknown_constraints = sorted(set(raw_constraints) - CONSTRAINT_FIELDS) + if unknown_constraints: + errors.append("constraints contains unsupported fields: " + ", ".join(unknown_constraints)) + for field in ("excluded_platforms", "excluded_paths"): + value = raw_constraints.get(field, []) + if not isinstance(value, list) or len(_string_list(value)) != len(value): + errors.append(f"constraints.{field} must be an array of non-empty strings") + for path in _string_list(raw_constraints.get("excluded_paths")): + normalized = path.replace("\\", "/") + if normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized) or ".." in Path(normalized).parts: + errors.append("constraints.excluded_paths must use repository-relative patterns") + break + level = raw_constraints.get("validation_level", "targeted") + if level not in VALIDATION_LEVELS: + errors.append("constraints.validation_level must be targeted, basic, or standard") + packages = data.get("work_packages") if not isinstance(packages, list) or not packages: errors.append("at least one work package is required") @@ -465,6 +862,48 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: return _dedupe(errors), _dedupe(warnings) +def _validate_research_route(data: dict[str, Any]) -> list[str]: + """Validate optional route state while accepting older closure task files.""" + route_present = "research_route" in data + related_present = any( + field in data for field in ("research_change_fields", "grill_completed") + ) + if not route_present and not related_present: + return [] + + errors: list[str] = [] + route = data.get("research_route") + if route not in RESEARCH_ROUTES: + return ["research_route must be delivery, execution, or exploration"] + raw_fields = data.get("research_change_fields") + if not isinstance(raw_fields, list) or any( + not isinstance(item, str) or not item.strip() for item in raw_fields + ): + errors.append("research_change_fields must be an array of non-empty strings") + fields: list[str] = [] + else: + fields = _dedupe(raw_fields) + invalid = sorted(set(fields) - RESEARCH_CHANGE_FIELDS) + if invalid: + errors.append( + "research_change_fields contains unsupported fields: " + + ", ".join(invalid) + ) + grill_completed = data.get("grill_completed") + if not isinstance(grill_completed, bool): + errors.append("grill_completed must be a boolean") + if route == "exploration": + if not fields: + errors.append("exploration requires at least one research change field") + if grill_completed is not True: + errors.append("exploration requires a completed grill before validation") + elif fields: + errors.append("research protocol changes require the exploration route") + if "model_architecture" in fields and route != "exploration": + errors.append("model_architecture changes require the exploration route") + return errors + + def validate_and_ready( task_dir: Path, data: dict[str, Any], @@ -480,8 +919,8 @@ def validate_and_ready( old_phase = str(data.get("hermes_phase") or "planning") _refresh_ready_packages(data) data["hermes_phase"] = "ready" - data["status"] = "planning" - data["next_action"] = _next_package_action(data) + data["status"] = "in_progress" + data["next_action"] = closure_next_action(data) save_task(task_dir, data) if old_phase != "ready": append_event( @@ -552,7 +991,7 @@ def start_package( data["current_work_package"] = package["id"] data["hermes_phase"] = "running" data["status"] = "in_progress" - data["next_action"] = f"Complete {package['id']} outcome, then run package-check." + data["next_action"] = closure_next_action(data) save_task(task_dir, data) if old_phase != "running": append_event( @@ -589,12 +1028,17 @@ def check_package( package = package_by_id(data, resolved) if package.get("status") != "running": raise ClosureError(f"{resolved} must be running before review") + dispatch_blockers = _string_list(package.get("dispatch_blockers")) + if dispatch_blockers: + raise ClosureError( + f"{resolved} has unresolved dispatch blockers: {', '.join(dispatch_blockers)}" + ) old_phase = str(data.get("hermes_phase") or "running") package["status"] = "review" data["current_work_package"] = resolved data["hermes_phase"] = "review" data["status"] = "in_progress" - data["next_action"] = f"Validate {resolved} done_when and record evidence before package-done." + data["next_action"] = closure_next_action(data) save_task(task_dir, data) append_event( task_dir, @@ -630,14 +1074,32 @@ def complete_package( current_id = data.get("current_work_package") if current_id and current_id != resolved: raise ClosureError(f"{current_id} is still the current work package") + dispatch_blockers = _string_list(package.get("dispatch_blockers")) + if dispatch_blockers: + raise ClosureError( + f"{resolved} has unresolved dispatch blockers: {', '.join(dispatch_blockers)}" + ) refs = _dedupe([*package.get("evidence_refs", []), *(evidence_refs or [])]) if disposition == "done": if old_status != "review": raise ClosureError(f"{resolved} must be in review before done") if not refs: raise ClosureError(f"{resolved} requires validation evidence before done") + invalid_refs = _invalid_evidence_refs(task_dir, refs) + if invalid_refs: + raise ClosureError( + f"{resolved} evidence refs must be existing repository files or evidence ledger ids: " + + ", ".join(invalid_refs) + ) + missing_required = _missing_required_evidence(task_dir, package, refs) + if missing_required: + raise ClosureError( + f"{resolved} is missing required evidence: " + ", ".join(missing_required) + ) elif not reason.strip(): raise ClosureError(f"{disposition} requires a reason") + if disposition == "done": + _validate_blank_bootstrap_completion(task_dir, data, resolved) package["status"] = disposition package["evidence_refs"] = refs package["blocker"] = None @@ -652,10 +1114,10 @@ def complete_package( for item in data.get("work_packages") or [] ): data["hermes_phase"] = "review" - data["next_action"] = "Run closure.py audit, repair bounded gaps, then close." + data["next_action"] = closure_next_action(data) else: data["hermes_phase"] = "running" - data["next_action"] = _next_package_action(data) + data["next_action"] = closure_next_action(data) data["status"] = "in_progress" save_task(task_dir, data) new_phase = str(data.get("hermes_phase") or "running") @@ -740,6 +1202,7 @@ def audit_closure( write_report: bool = False, ) -> dict[str, Any]: gaps: list[dict[str, Any]] = [] + warnings: list[str] = [] errors, _ = validate_closure(data) for error in errors: gaps.append(_gap(None, error, "修正 task.json closure plan 后重新 validate")) @@ -751,14 +1214,40 @@ def audit_closure( for package in packages: package_id = str(package.get("id") or "") status = package.get("status") + dispatch_blockers = _string_list(package.get("dispatch_blockers")) + if dispatch_blockers: + gaps.append( + _gap( + package_id, + f"dispatch blockers: {', '.join(dispatch_blockers)}", + "supersede or resolve the blocked dispatch", + ) + ) if status not in DISPOSED_PACKAGE_STATUSES: gaps.append(_gap(package_id, f"package status is {status}", "完成、defer 或 waive 该工作包")) - if status == "done" and not _string_list(package.get("evidence_refs")): - gaps.append(_gap(package_id, "validation evidence", "登记基础验证 evidence_refs")) - if status == "done" and package.get("evidence_required"): - required_count = len(_string_list(package.get("evidence_required"))) - if len(_string_list(package.get("evidence_refs"))) < required_count: - gaps.append(_gap(package_id, "required evidence", "补齐 evidence_required 对应引用")) + if status == "done": + evidence_refs = _string_list(package.get("evidence_refs")) + invalid_refs = _invalid_evidence_refs(task_dir, evidence_refs) + if not evidence_refs: + gaps.append(_gap(package_id, "validation evidence", "登记基础验证 evidence_refs")) + elif invalid_refs: + gaps.append( + _gap( + package_id, + "invalid evidence refs: " + ", ".join(invalid_refs), + "使用已有仓库文件或 evidence ledger id 重新登记证据", + ) + ) + missing_required = _missing_required_evidence(task_dir, package, evidence_refs) + if missing_required: + gaps.append( + _gap( + package_id, + "required evidence: " + ", ".join(missing_required), + "补齐 evidence_required 对应引用", + ) + ) + gaps.extend(_blank_bootstrap_gaps(task_dir, data)) done_clauses = { clause.casefold().strip() @@ -779,6 +1268,15 @@ def audit_closure( gaps.append(_gap(None, blocker, "解决 blocker 或经批准调整计划")) mode = str(data.get("closure_mode") or "lean") + runtime_errors, runtime_warnings = closure_mode_gate( + get_repo_root(task_dir), + mode, + task_id=str(data.get("id") or task_dir.name), + ) + for error in runtime_errors: + gaps.append(_gap(None, "context firewall", error)) + warnings.extend(runtime_warnings) + gaps.extend(_dispatch_gaps(task_dir, mode)) if mode in {"standard", "publication"}: gaps.extend(_standard_gaps(task_dir)) if mode == "publication": @@ -791,7 +1289,146 @@ def audit_closure( gaps.append(_gap(None, "closure-report.md", "生成简短 closure report")) status = "all_met" if not gaps else "blocked" if blockers or data.get("hermes_phase") == "blocked" else "has_gaps" - return {"status": status, "gaps": gaps, "mode": mode} + return {"status": status, "gaps": gaps, "mode": mode, "warnings": warnings} + + +def _is_blank_bootstrap(data: dict[str, Any]) -> bool: + meta = data.get("meta") + return ( + isinstance(meta, dict) + and meta.get("hermes_bootstrap_kind") == "blank" + and str(data.get("id") or "") == "00-bootstrap-guidelines" + ) + + +def _project_context_has_content(path: Path) -> bool: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return False + for line in lines: + value = line.strip() + if not value or value.startswith("#") or value.startswith(" [workflow-state:planning] -Hermes closure: use the injected Task Capsule first. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. -Load `trellis-brainstorm`; stay in planning. +Hermes closure: use the injected Task Capsule first. Before every plan or dispatch, the main agent must analyze intent, scope, completion conditions, route, risks, and the smallest useful work-package split. Run `closure.py plan` when packages are absent, then `closure.py validate`; keep one task with 1-4 observable-result packages by default. Five or more packages only trigger a split recommendation. +Do not load `trellis-brainstorm` or `trellis-grill-me` by default. Use planning evidence directly for clear deterministic work; use brainstorming only for missing requirements, and grill only for a material unresolved solution, architecture, research-design, or scope decision, or when the user explicitly asks. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. -Planning order for this Claude Code path: `task.py create` → `trellis-brainstorm` → `trellis-grill-me` → development strategy decision. -`trellis-grill-me` is a required planning gate on this Claude Code path, not an optional suggestion. -Before `trellis-grill-me` is complete, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`. +Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the same task-level review-gate set. [/workflow-state:planning] @@ -258,7 +274,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow only the current package in the Task Capsule. Move running -> review with `package-check`, record validation with `package-done`, then run `audit`; use at most the configured bounded `repair` rounds, and run `close` before archive. Do not load all reports, events, ledgers, history, or specs by default. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. diff --git a/packages/cli/src/utils/project-context.ts b/packages/cli/src/utils/project-context.ts new file mode 100644 index 0000000..97a49b7 --- /dev/null +++ b/packages/cli/src/utils/project-context.ts @@ -0,0 +1,211 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type ProjectInitializationKind = "existing" | "blank"; + +export interface ProjectFactIndex { + kind: ProjectInitializationKind; + documentation: string[]; + conventions: string[]; + configuration: string[]; + implementation: string[]; + records: string[]; +} + +const IGNORED_ENTRIES = new Set([ + ".git", + ".trellis", + ".claude", + ".codex", + ".agents", + "node_modules", + ".venv", + "venv", + "__pycache__", + "dist", + "build", + "coverage", +]); + +const DOCUMENTATION_NAMES = new Set([ + "README.md", + "README", + "CONTRIBUTING.md", + "CHANGELOG.md", + "DESIGN.md", + "ARCHITECTURE.md", + "CONTEXT.md", + "PROJECT.md", +]); + +const CONVENTION_NAMES = new Set([ + "AGENTS.md", + "CLAUDE.md", + "CLAUDE.local.md", + "CONVENTIONS.md", + ".editorconfig", + ".cursorrules", + ".windsurfrules", + ".clinerules", +]); + +const CONFIGURATION_NAMES = new Set([ + "package.json", + "pnpm-workspace.yaml", + "pyproject.toml", + "requirements.txt", + "poetry.lock", + "Cargo.toml", + "go.mod", + "go.work", + "pom.xml", + "build.gradle", + "docker-compose.yml", + "Dockerfile", + ".gitmodules", +]); + +const IMPLEMENTATION_DIRECTORIES = new Set([ + "src", + "app", + "lib", + "packages", + "apps", + "services", + "server", + "client", + "tests", + "test", + "scripts", +]); + +const RECORD_DIRECTORIES = new Set([ + "docs", + "reports", + "results", + "artifacts", + "data", + "notebooks", + "experiments", +]); + +const IMPLEMENTATION_EXTENSIONS = new Set([ + ".c", + ".cc", + ".cpp", + ".cs", + ".go", + ".java", + ".js", + ".jsx", + ".kt", + ".py", + ".rb", + ".rs", + ".sh", + ".swift", + ".ts", + ".tsx", +]); + +function sorted(values: Iterable): string[] { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +/** + * Inspect only stable, root-level facts present before Trellis writes its own + * files. This is deliberately an index, not a repository summary: callers + * must still read the referenced source before adding project claims. + */ +export function collectProjectFactIndex(cwd: string): ProjectFactIndex { + const documentation = new Set(); + const conventions = new Set(); + const configuration = new Set(); + const implementation = new Set(); + const records = new Set(); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(cwd, { withFileTypes: true }); + } catch { + // The init command will surface normal filesystem failures elsewhere. An + // empty index here keeps this helper deterministic and easy to recover. + } + + for (const entry of entries) { + if (IGNORED_ENTRIES.has(entry.name)) continue; + const relative = entry.name; + + if (entry.isFile()) { + if (DOCUMENTATION_NAMES.has(entry.name)) documentation.add(relative); + if (CONVENTION_NAMES.has(entry.name)) conventions.add(relative); + if (CONFIGURATION_NAMES.has(entry.name)) configuration.add(relative); + if (IMPLEMENTATION_EXTENSIONS.has(path.extname(entry.name))) { + implementation.add(relative); + } + continue; + } + + if (!entry.isDirectory()) continue; + if (IMPLEMENTATION_DIRECTORIES.has(entry.name)) + implementation.add(`${relative}/`); + if (RECORD_DIRECTORIES.has(entry.name)) records.add(`${relative}/`); + + if (entry.name === ".cursor") { + const rules = path.join(cwd, entry.name, "rules"); + if (fs.existsSync(rules)) conventions.add(".cursor/rules/"); + } + } + + const index = { + documentation: sorted(documentation), + conventions: sorted(conventions), + configuration: sorted(configuration), + implementation: sorted(implementation), + records: sorted(records), + }; + const kind: ProjectInitializationKind = Object.values(index).some( + (values) => values.length > 0, + ) + ? "existing" + : "blank"; + + return { kind, ...index }; +} + +function section(title: string, entries: string[]): string { + const lines = + entries.length > 0 + ? entries.map((entry) => `- \`${entry}\``) + : ["- None found at initialization time."]; + return `## ${title}\n\n${lines.join("\n")}`; +} + +/** Render a stable, factual index without inferring facts from source files. */ +export function renderProjectFactIndex(index: ProjectFactIndex): string { + const introduction = + index.kind === "existing" + ? "This index lists root-level facts found before Trellis initialization. Read the referenced material before recording project background, research claims, or engineering rules." + : "No project documentation, configuration, source, or record directories were found before Trellis initialization. Establish the research contract before creating implementation work."; + + return [ + "# Project Fact Index", + "", + introduction, + "", + `Initialization kind: **${index.kind}**`, + "", + section("Documentation", index.documentation), + "", + section("Conventions", index.conventions), + "", + section("Configuration", index.configuration), + "", + section("Implementation", index.implementation), + "", + section("Records And Data", index.records), + "", + "Do not treat this index as a project summary. It is a compact reading map for the main agent and the startup task.", + "", + ].join("\n"); +} diff --git a/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts new file mode 100644 index 0000000..fb38e3d --- /dev/null +++ b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts @@ -0,0 +1,104 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { init } from "../../src/commands/init.js"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +function run(root: string, script: string, args: string[]) { + return spawnSync(PYTHON, [path.join(root, ".trellis", "scripts", script), ...args], { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }); +} + +describe("empty repository bootstrap closure", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("moves an empty repository from research contract to archived bootstrap task", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-blank-bootstrap-")); + roots.push(root); + const cwd = process.cwd(); + process.chdir(root); + try { + await init({ yes: true, force: true, user: "alice" }); + + const project = path.join(root, ".trellis", "project"); + fs.writeFileSync(path.join(project, "BACKGROUND.md"), "# Background\n\nGoal: reproduce one agreed sample.\n"); + fs.writeFileSync(path.join(project, "RESEARCH_PLAN.md"), "# Research Plan\n\nMethod: run one fixed sample.\n"); + fs.writeFileSync(path.join(project, "CONSTRAINTS.md"), "# Constraints\n\nLimit: keep the first task small.\n"); + + const closure = (args: string[]) => run(root, "closure.py", args); + expect(closure(["grill", "--task", "00-bootstrap-guidelines", "--complete"]).status).toBe(0); + expect(closure(["validate", "--task", "00-bootstrap-guidelines"]).status).toBe(0); + expect(closure(["package-start", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); + expect(closure(["package-check", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); + expect(closure([ + "package-done", "--task", "00-bootstrap-guidelines", "--package-id", "WP1", + "--evidence", ".trellis/project/BACKGROUND.md", + "--evidence", ".trellis/project/RESEARCH_PLAN.md", + "--evidence", ".trellis/project/CONSTRAINTS.md", + ]).status).toBe(0); + + const created = run(root, "task.py", [ + "create", "Verify the first sample", "--slug", "first-sample", "--assignee", "alice", + ]); + expect(created.status, created.stderr).toBe(0); + const firstTask = created.stdout.trim().split("\n").at(-1); + if (!firstTask) { + throw new Error("task.py create did not return a task path"); + } + expect(closure([ + "plan", "--task", firstTask, + "--intent", "Verify one agreed minimal result", + "--done-when", "The agreed sample has a reproducible result", + ]).status).toBe(0); + + expect(closure(["package-start", "--task", "00-bootstrap-guidelines", "--package-id", "WP2"]).status).toBe(0); + expect(closure(["package-check", "--task", "00-bootstrap-guidelines", "--package-id", "WP2"]).status).toBe(0); + expect(closure([ + "package-done", "--task", "00-bootstrap-guidelines", "--package-id", "WP2", + "--evidence", `${firstTask}/task.json`, + ]).status).toBe(0); + const audit = closure(["audit", "--task", "00-bootstrap-guidelines"]); + expect(audit.status, audit.stderr).toBe(0); + const close = closure(["close", "--task", "00-bootstrap-guidelines"]); + expect(close.status, close.stderr).toBe(0); + + const archive = run(root, "task.py", ["archive", "00-bootstrap-guidelines", "--no-commit"]); + expect(archive.status, archive.stderr).toBe(0); + const archivePath = archive.stdout.trim().split("\n").at(-1); + if (!archivePath) { + throw new Error("task.py archive did not return an archive path"); + } + const archivedTask = JSON.parse( + fs.readFileSync(path.join(root, archivePath, "task.json"), "utf-8"), + ) as { status: string; hermes_phase: string; closure_state: string }; + expect(archivedTask).toMatchObject({ + status: "completed", + hermes_phase: "closed", + closure_state: "closed", + }); + expect(fs.existsSync(path.join(root, archivePath, "closure-report.md"))).toBe(true); + const events = fs.readFileSync(path.join(root, archivePath, "hermes", "task-events.jsonl"), "utf-8"); + expect(events).toContain("package_completed"); + expect(events).toContain("task_closed"); + } finally { + process.chdir(cwd); + } + }); +}); diff --git a/packages/cli/test/commands/init.integration.test.ts b/packages/cli/test/commands/init.integration.test.ts index 437bb53..ad32d5b 100644 --- a/packages/cli/test/commands/init.integration.test.ts +++ b/packages/cli/test/commands/init.integration.test.ts @@ -70,6 +70,43 @@ describe("init() integration", () => { expect(fs.existsSync(path.join(tmpDir, PATHS.WORKSPACE))).toBe(true); expect(fs.existsSync(path.join(tmpDir, PATHS.TASKS))).toBe(true); expect(fs.existsSync(path.join(tmpDir, PATHS.SPEC))).toBe(true); + expect( + fs.existsSync( + path.join(tmpDir, PATHS.SPEC, "guides", "general-code-guidelines.md"), + ), + ).toBe(true); + const generalCodeGuidelines = fs.readFileSync( + path.join(tmpDir, PATHS.SPEC, "guides", "general-code-guidelines.md"), + "utf-8", + ); + expect(generalCodeGuidelines).toContain("规则优先级"); + expect(generalCodeGuidelines).toContain("科研代码与可复现性"); + expect(generalCodeGuidelines).toContain("不会自动加入每个任务"); + for (const filename of [ + "index.md", + "typescript-javascript.md", + "python.md", + "go.md", + "rust.md", + "cpp.md", + "shell.md", + ]) { + expect( + fs.existsSync(path.join(tmpDir, PATHS.SPEC, "languages", filename)), + ).toBe(true); + } + expect( + fs.existsSync(path.join(tmpDir, PATHS.PROJECT, "BACKGROUND.md")), + ).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, PATHS.PROJECT, "RESEARCH_PLAN.md")), + ).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, PATHS.PROJECT, "CONSTRAINTS.md")), + ).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, PATHS.PROJECT, "PROJECT_INDEX.md")), + ).toBe(true); // Default platforms: cursor + claude expect(fs.existsSync(path.join(tmpDir, ".cursor"))).toBe(true); @@ -122,7 +159,7 @@ describe("init() integration", () => { ).toBe(true); expect( fs.existsSync( - path.join(tmpDir, ".trellis", "hermes", "roles", "scientist.md"), + path.join(tmpDir, ".trellis", "hermes", "roles", "planner.md"), ), ).toBe(true); expect( @@ -156,6 +193,8 @@ describe("init() integration", () => { "runtime.py", "record.py", "validate.py", + "evidence.py", + "dispatch.py", "experiment.py", "guard.py", "jobs.py", @@ -171,7 +210,7 @@ describe("init() integration", () => { } expect( fs.existsSync( - path.join(tmpDir, ".claude", "agents", "hermes-scientist.md"), + path.join(tmpDir, ".claude", "agents", "hermes-planner.md"), ), ).toBe(true); expect( @@ -181,6 +220,77 @@ describe("init() integration", () => { ).toBe(true); }); + it("#1a creates a focused research-contract startup task for an empty repository", async () => { + await init({ yes: true, user: "alice", codex: true }); + + const taskDir = path.join(tmpDir, PATHS.TASKS, "00-bootstrap-guidelines"); + const task = JSON.parse( + fs.readFileSync(path.join(taskDir, FILE_NAMES.TASK_JSON), "utf-8"), + ); + const index = fs.readFileSync( + path.join(tmpDir, PATHS.PROJECT, "PROJECT_INDEX.md"), + "utf-8", + ); + const prd = fs.readFileSync(path.join(taskDir, FILE_NAMES.PRD), "utf-8"); + + expect(index).toContain("Initialization kind: **blank**"); + expect(task.status).toBe("planning"); + expect(task.hermes_phase).toBe("planning"); + expect(task.research_route).toBe("exploration"); + expect(task.work_packages).toHaveLength(2); + expect(task.work_packages[0].title).toContain("research contract"); + expect(prd).toContain("六项"); + expect(prd).toContain("可验证的首个研究任务"); + }); + + it("#1c indexes an existing repository without copying its contents into project context", async () => { + fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "README.md"), + "PRIVATE_PROJECT_FACT\n", + "utf-8", + ); + fs.writeFileSync( + path.join(tmpDir, "AGENTS.md"), + "Existing convention\n", + "utf-8", + ); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + '{"name":"existing-project"}\n', + "utf-8", + ); + fs.writeFileSync( + path.join(tmpDir, "src", "main.ts"), + "export {};\n", + "utf-8", + ); + + await init({ yes: true, user: "alice", codex: true }); + + const index = fs.readFileSync( + path.join(tmpDir, PATHS.PROJECT, "PROJECT_INDEX.md"), + "utf-8", + ); + const taskDir = path.join(tmpDir, PATHS.TASKS, "00-bootstrap-guidelines"); + const task = JSON.parse( + fs.readFileSync(path.join(taskDir, FILE_NAMES.TASK_JSON), "utf-8"), + ); + const prd = fs.readFileSync(path.join(taskDir, FILE_NAMES.PRD), "utf-8"); + + expect(index).toContain("Initialization kind: **existing**"); + expect(index).toContain("`README.md`"); + expect(index).toContain("`AGENTS.md`"); + expect(index).toContain("`package.json`"); + expect(index).toContain("`src/`"); + expect(index).not.toContain("PRIVATE_PROJECT_FACT"); + expect(task.research_route).toBe("delivery"); + expect(task.work_packages).toHaveLength(2); + expect(prd).toContain("PROJECT_INDEX.md"); + expect(prd).toContain("不要推测"); + }); + it("#1b does not print the promotional pain-point block", async () => { await init({ yes: true }); @@ -1062,9 +1172,9 @@ describe("init() integration", () => { expect(Array.isArray(taskJson.subtasks)).toBe(true); expect(taskJson.subtasks).toEqual([]); - // Legacy current_phase stays absent; v0.7 closure owns next_action. + // Legacy current_phase stays absent; the closure task owns next_action. expect(taskJson.current_phase).toBeUndefined(); - expect(taskJson.next_action).toBeNull(); + expect(taskJson.next_action).toContain("PROJECT_INDEX.md"); // relatedFiles point to spec// expect(taskJson.relatedFiles).toContain(".trellis/spec/core/"); diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index 0221adf..d1da0e6 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -263,6 +263,46 @@ describe("update() integration", () => { expect(entries.filter((e) => e.startsWith(".backup-")).length).toBe(0); }); + it("adds missing project context and language guides when upgrading a pre-v0.7.1 project", async () => { + await setupProject(); + + const newResearchFiles = [ + `${PATHS.PROJECT}/README.md`, + `${PATHS.PROJECT}/BACKGROUND.md`, + `${PATHS.PROJECT}/RESEARCH_PLAN.md`, + `${PATHS.PROJECT}/CONSTRAINTS.md`, + `${PATHS.PROJECT}/PROJECT_INDEX.md`, + `${PATHS.SPEC}/guides/general-code-guidelines.md`, + `${PATHS.SPEC}/languages/index.md`, + `${PATHS.SPEC}/languages/typescript-javascript.md`, + `${PATHS.SPEC}/languages/python.md`, + `${PATHS.SPEC}/languages/go.md`, + `${PATHS.SPEC}/languages/rust.md`, + `${PATHS.SPEC}/languages/cpp.md`, + `${PATHS.SPEC}/languages/shell.md`, + ]; + const hashFile = hashFilePath(); + let hashes = readHashesV2(hashFile); + for (const relativePath of newResearchFiles) { + fs.rmSync(projectFile(relativePath), { force: true }); + hashes = removeHashEntry(hashes, relativePath) as Record; + } + writeHashesV2(hashFile, hashes); + fs.writeFileSync(versionFilePath(), "0.7.0-beta.0"); + + await update({ force: true }); + + for (const relativePath of newResearchFiles) { + expect(fs.existsSync(projectFile(relativePath))).toBe(true); + } + expect(readProjectFile(`${PATHS.PROJECT}/PROJECT_INDEX.md`)).toContain( + "Project Fact Index", + ); + expect(readProjectFile(`${PATHS.SPEC}/languages/index.md`)).toContain( + "TypeScript", + ); + }); + it("#3 user-deleted file (with stored hash) is not re-added on update", async () => { await setupProject(); @@ -710,10 +750,11 @@ describe("update() integration", () => { const hermesPaths = [ ".trellis/hermes/config.yaml", ".trellis/hermes/state_machine.yaml", - ".trellis/hermes/roles/scientist.md", + ".trellis/hermes/roles/planner.md", ".trellis/hermes/records/README.md", ".trellis/hermes/records/recordbus.md", ".trellis/hermes/records/subagent_protocol.md", + ".trellis/hermes/schemas/result-envelope.schema.json", ".trellis/hermes/ledgers/.gitkeep", ".trellis/scripts/hermes/__init__.py", ".trellis/scripts/hermes/runtime.py", @@ -723,6 +764,7 @@ describe("update() integration", () => { ".trellis/scripts/hermes/guard.py", ".trellis/scripts/hermes/jobs.py", ".trellis/scripts/hermes/heartbeat.py", + ".trellis/scripts/hermes/dispatch.py", ".trellis/scripts/hermes/runner.py", ".trellis/scripts/hermes/report.py", ]; diff --git a/packages/cli/test/constants/paths.test.ts b/packages/cli/test/constants/paths.test.ts index 285ea14..ee4d0bb 100644 --- a/packages/cli/test/constants/paths.test.ts +++ b/packages/cli/test/constants/paths.test.ts @@ -19,6 +19,7 @@ describe("DIR_NAMES", () => { expect(DIR_NAMES).toHaveProperty("TASKS"); expect(DIR_NAMES).toHaveProperty("ARCHIVE"); expect(DIR_NAMES).toHaveProperty("SPEC"); + expect(DIR_NAMES).toHaveProperty("PROJECT"); expect(DIR_NAMES).toHaveProperty("SCRIPTS"); }); @@ -83,6 +84,10 @@ describe("PATHS", () => { expect(PATHS.SPEC).toBe(`${DIR_NAMES.WORKFLOW}/${DIR_NAMES.SPEC}`); }); + it("PROJECT is WORKFLOW/project", () => { + expect(PATHS.PROJECT).toBe(`${DIR_NAMES.WORKFLOW}/${DIR_NAMES.PROJECT}`); + }); + it("SCRIPTS is WORKFLOW/scripts", () => { expect(PATHS.SCRIPTS).toBe(`${DIR_NAMES.WORKFLOW}/${DIR_NAMES.SCRIPTS}`); }); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index 9004b1e..cd488ac 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -1255,11 +1255,12 @@ describe("regression: current-task path normalization", () => { const copilotSessionStart = getCopilotHooks().find( (hook) => hook.name === "session-start.py", )?.content; - it("[session-current-task] session-start template keeps trellis-grill-me as a required Claude Code planning gate", () => { + it("[session-current-task] analyzes every task but limits grill to unresolved decisions", () => { expect(claudeSessionStart).toContain("guide the user to `task.py create` first"); - expect(workflowMdTemplate).toContain("Planning order for this Claude Code path: `task.py create` → `trellis-brainstorm` → `trellis-grill-me` → development strategy decision."); - expect(workflowMdTemplate).toContain("`trellis-grill-me` is a required planning gate on this Claude Code path, not an optional suggestion."); - expect(workflowMdTemplate).toContain("Before `trellis-grill-me` is complete, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start`."); + expect(workflowMdTemplate).toContain("Before every plan or dispatch, the main agent must analyze intent, scope, completion conditions, route, risks, and the smallest useful work-package split."); + expect(workflowMdTemplate).toContain("Do not load `trellis-brainstorm` or `trellis-grill-me` by default."); + expect(workflowMdTemplate).toContain("optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision"); + expect(workflowMdTemplate).toContain("When a grill is running, do not enter development strategy decisions"); }); beforeEach(() => { diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts new file mode 100644 index 0000000..b7ab13a --- /dev/null +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -0,0 +1,1117 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + getAllHermesTemplates, + getAllScripts, +} from "../../src/templates/trellis/index.js"; +import { getSharedHookScripts } from "../../src/templates/shared-hooks/index.js"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +interface CommandResult { + status: number | null; + stdout: string; + stderr: string; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); +} + +function writeJsonl(filePath: string, values: unknown[]): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `${values.map((value) => JSON.stringify(value)).join("\n")}\n`, + "utf-8", + ); +} + +function baseTask(overrides: Record = {}) { + return { + id: "demo", + title: "Firewall demo", + description: "Verify bounded agent context", + status: "in_progress", + hermes_revision: 4, + hermes_phase: "running", + closure_state: "open", + closure_mode: "lean", + intent: "Verify bounded agent context", + in_scope: ["firewall"], + out_of_scope: [], + definition_of_done: ["Firewall behavior is verified"], + work_packages: [ + { + id: "WP1", + title: "Implement firewall", + outcome: "Firewall behavior exists", + done_when: ["Firewall behavior is verified"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }, + ], + current_work_package: "WP1", + next_action: "Implement WP1", + blockers: [], + repair_count: 0, + max_repair_count: 1, + ...overrides, + }; +} + +describe("Agent Context Firewall dispatch CLI", () => { + let root: string; + let taskDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-firewall-")); + spawnSync("git", ["init", "-q", "-b", "main"], { + cwd: root, + encoding: "utf-8", + }); + taskDir = path.join(root, ".trellis", "tasks", "demo"); + for (const [relativePath, content] of getAllScripts()) { + const target = path.join(root, ".trellis", "scripts", relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, "utf-8"); + } + for (const [relativePath, content] of getAllHermesTemplates()) { + const target = path.join(root, ".trellis", "hermes", relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, "utf-8"); + } + for (const hook of getSharedHookScripts()) { + const target = path.join(root, ".claude", "hooks", hook.name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, hook.content, "utf-8"); + } + writeJson(path.join(taskDir, "task.json"), baseTask()); + fs.writeFileSync(path.join(taskDir, "prd.md"), "# PRD\n", "utf-8"); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "app.ts"), "export {};\n", "utf-8"); + const sessions = path.join(root, ".trellis", ".runtime", "sessions"); + fs.mkdirSync(sessions, { recursive: true }); + fs.writeFileSync( + path.join(sessions, "firewall-session.json"), + `${JSON.stringify({ current_task: ".trellis/tasks/demo" })}\n`, + "utf-8", + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function run( + args: string[], + env: Record = {}, + ): CommandResult { + const result = spawnSync( + PYTHON, + [path.join(root, ".trellis", "scripts", "hermes", "dispatch.py"), ...args], + { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + ...env, + }, + }, + ); + return { + status: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; + } + + function create( + jobId: string, + role = "coder", + extra: string[] = [], + ): CommandResult { + const args = [ + "create", + "--task", + "demo", + "--job-id", + jobId, + "--role", + role, + "--objective", + `Complete ${jobId}`, + "--ref", + "prd.md", + ]; + if (["coder", "runner"].includes(role)) { + args.push("--work-package", "WP1"); + } + if (role === "coder") { + args.push("--allowed-file", "src/**"); + } + args.push(...extra); + return run(args); + } + + function apply(jobId: string, value: Record): CommandResult { + return run([ + "apply", + "--task", + "demo", + "--job-id", + jobId, + "--result-json", + JSON.stringify(value), + ]); + } + + function validResult(jobId: string, overrides: Record = {}) { + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", `${jobId}.dispatch.json`), + "utf-8", + ), + ) as Record; + return { + schema: "hermes-result/v1", + job_id: jobId, + task_revision: dispatch.task_revision, + role: dispatch.role, + profile: dispatch.profile, + status: "success", + conclusion: "Bounded work completed.", + uncertainties: [], + changed_files: ["src/app.ts"], + evidence_refs: [], + artifact_refs: [], + verification: { status: "not_recorded", run_refs: [] }, + risks: [], + next_action: "Review the current package.", + ...overrides, + }; + } + + function runHook( + hookName: "inject-subagent-context.py" | "hermes-runtime-guard.py", + input: Record, + ): CommandResult { + const result = spawnSync( + PYTHON, + [path.join(root, ".claude", "hooks", hookName)], + { + cwd: root, + encoding: "utf-8", + input: JSON.stringify(input), + env: { + ...process.env, + CLAUDE_PROJECT_DIR: root, + TRELLIS_CONTEXT_ID: "firewall-session", + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); + return { + status: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; + } + + it("creates a revision-bound canonical dispatch with exact metrics", () => { + const result = create("job-create"); + expect(result.status).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-create.dispatch.json"), + "utf-8", + ), + ) as { + hermes_revision: number; + refs: string[]; + body: string; + audit: { metrics: { body_chars: number; ref_count: number } }; + }; + expect(dispatch.hermes_revision).toBe(4); + expect(dispatch.refs).toEqual([".trellis/tasks/demo/prd.md"]); + expect(dispatch.body.length).toBeLessThanOrEqual(2000); + expect(dispatch.audit.metrics.body_chars).toBe(dispatch.body.length); + expect(dispatch.audit.metrics.ref_count).toBe(1); + }); + + it("adds only role-matched project context after explicit task 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, "RESEARCH_PLAN.md"), "Plan\n"); + fs.writeFileSync(path.join(projectDir, "CONSTRAINTS.md"), "Constraints\n"); + fs.writeFileSync(path.join(taskDir, "specific-spec.md"), "Spec\n"); + fs.writeFileSync(path.join(taskDir, "evidence.md"), "Evidence\n"); + + expect(create("job-project-coder", "coder").status).toBe(0); + expect(create("job-project-runner", "runner").status).toBe(0); + expect( + create("job-project-review", "reviewer", ["--profile", "evidence"]) + .status, + ).toBe(0); + expect( + create("job-project-manual", "coder", [ + "--ref", + "specific-spec.md", + "--ref", + "evidence.md", + ]).status, + ).toBe(0); + + const refsFor = (jobId: string): string[] => ( + JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", `${jobId}.dispatch.json`), + "utf-8", + ), + ) as { refs: string[] } + ).refs; + expect(refsFor("job-project-coder")).toEqual([ + ".trellis/tasks/demo/prd.md", + ".trellis/project/CONSTRAINTS.md", + ]); + expect(refsFor("job-project-runner")).toEqual([ + ".trellis/tasks/demo/prd.md", + ".trellis/project/RESEARCH_PLAN.md", + ".trellis/project/CONSTRAINTS.md", + ]); + expect(refsFor("job-project-review")).toEqual([ + ".trellis/tasks/demo/prd.md", + ".trellis/project/RESEARCH_PLAN.md", + ".trellis/project/CONSTRAINTS.md", + ]); + expect(refsFor("job-project-manual")).toEqual([ + ".trellis/tasks/demo/prd.md", + ".trellis/tasks/demo/specific-spec.md", + ".trellis/tasks/demo/evidence.md", + ]); + }); + + it("allows planner and reviewer task-level work without a package", () => { + expect(create("job-planner", "planner").status).toBe(0); + expect(create("job-review", "reviewer").status).toBe(0); + const review = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-review.dispatch.json"), + "utf-8", + ), + ) as Record; + expect(review.work_package).toBeNull(); + expect(review.blind_review).toBe(true); + }); + + it("rejects missing jobs, stale revisions, and execution without the current package", () => { + const missing = run(["validate", "--task", "demo", "--job-id", "missing"]); + expect(missing.status).toBe(1); + 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.hermes_revision = 5; + writeJson(path.join(taskDir, "task.json"), task); + const stale = run(["validate", "--task", "demo", "--job-id", "job-stale"]); + expect(stale.status).toBe(1); + expect(stale.stderr).toContain("stale_dispatch"); + + const missingPackage = run([ + "create", + "--task", + "demo", + "--job-id", + "job-no-wp", + "--role", + "runner", + "--objective", + "Run validation", + ]); + expect(missingPackage.stderr).toContain("missing_work_package"); + }); + + it("rejects excessive, missing, absolute, traversal, and sensitive refs", () => { + for (const name of ["a.md", "b.md", "c.md", "d.md"]) { + fs.writeFileSync(path.join(taskDir, name), name, "utf-8"); + } + const tooMany = run([ + "create", "--task", "demo", "--job-id", "job-refs", "--role", "planner", + "--objective", "Plan", "--ref", "a.md", "--ref", "b.md", "--ref", "c.md", "--ref", "d.md", + ]); + expect(tooMany.stderr).toContain("too_many_refs"); + 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"], + ["job-traversal", "../outside.md", "ref_out_of_bounds"], + ]) { + const result = run([ + "create", "--task", "demo", "--job-id", job, "--role", "planner", + "--objective", "Plan", "--ref", ref, + ]); + expect(result.stderr).toContain(code); + } + const sensitiveField = ["api", "key"].join("_"); + const syntheticSecret = "a".repeat(16); + const sensitive = run([ + "create", "--task", "demo", "--job-id", "job-secret", "--role", "planner", + "--objective", `Use ${sensitiveField}=${syntheticSecret}`, + ]); + expect(sensitive.stderr).toContain("sensitive_content"); + if (process.platform !== "win32") { + const outside = path.join(os.tmpdir(), `trellis-firewall-outside-${Date.now()}.md`); + fs.writeFileSync(outside, "outside\n", "utf-8"); + fs.symlinkSync(outside, path.join(root, "linked-outside.md")); + const linked = run([ + "create", "--task", "demo", "--job-id", "job-linked-ref", "--role", "planner", + "--objective", "Plan", "--ref", "linked-outside.md", + ]); + expect(linked.stderr).toContain("ref_out_of_bounds"); + fs.rmSync(outside, { force: true }); + } + }); + + it("applies closure constraints before creating an unrelated platform dispatch", () => { + writeJson( + path.join(taskDir, "task.json"), + baseTask({ + context_pins: ["src/app.ts"], + constraints: { + excluded_platforms: ["codex"], + excluded_paths: ["generated/**"], + validation_level: "targeted", + }, + }), + ); + const excluded = create("job-excluded-platform", "coder", [ + "--platform", + "codex", + ]); + expect(excluded.status).toBe(1); + expect(excluded.stderr).toContain("excluded_platform"); + + const allowed = create("job-constrained-claude"); + expect(allowed.status).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join( + taskDir, + "hermes", + "dispatches", + "job-constrained-claude.dispatch.json", + ), + "utf-8", + ), + ) as { forbidden_files: string[]; refs: string[] }; + expect(dispatch.refs).toEqual([ + "src/app.ts", + ".trellis/tasks/demo/prd.md", + ]); + expect(dispatch.forbidden_files).toContain("generated/**"); + }); + + it("allows publication packet creation but refuses execution without a hard gate", () => { + writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); + const result = run( + [ + "create", "--task", "demo", "--job-id", "job-publication", "--role", "planner", + "--objective", "Plan publication checks", + ], + { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, + ); + expect(result.status).toBe(0); + expect(result.stderr).toContain("execution and close remain blocked"); + const execution = run( + [ + "run", "--task", "demo", "--job-id", "job-publication", + "--platform", "codex", "--mode", "native", + ], + { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, + ); + expect(execution.status).toBe(1); + expect(execution.stderr).toContain("advisory_forbidden"); + }); + + it("rejects invalid JSON, missing uncertainties, long logs, and full diffs", () => { + expect(create("job-invalid-json").status).toBe(0); + const invalid = run([ + "apply", "--task", "demo", "--job-id", "job-invalid-json", "--result-json", "not json", + ]); + expect(invalid.stderr).toContain("invalid_json"); + + expect(create("job-missing-uncertainties").status).toBe(0); + const missing = validResult("job-missing-uncertainties"); + delete (missing as Record).uncertainties; + expect(apply("job-missing-uncertainties", missing).stderr).toContain("missing_result_fields"); + + expect(create("job-log").status).toBe(0); + const logConclusion = Array.from({ length: 7 }, (_, index) => `INFO line ${index}`).join("\n"); + expect(apply("job-log", validResult("job-log", { conclusion: logConclusion })).stderr).toContain("long_log"); + + expect(create("job-diff").status).toBe(0); + expect(apply("job-diff", validResult("job-diff", { conclusion: "diff --git a/a b/a" })).stderr).toContain("full_diff"); + }); + + it("blocks only the job/current package after two invalid envelopes", () => { + expect(create("job-twice").status).toBe(0); + for (let index = 0; index < 2; index += 1) { + expect(run([ + "apply", "--task", "demo", "--job-id", "job-twice", "--result-json", "{}", + ]).status).toBe(1); + } + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-twice.dispatch.json"), + "utf-8", + ), + ) as Record; + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(dispatch.status).toBe("blocked"); + expect(task.status).toBe("in_progress"); + expect(task.closure_state).toBe("open"); + expect(task.work_packages[0].status).toBe("blocked"); + expect(task.work_packages[0].dispatch_blockers).toEqual(["job-twice"]); + }); + + it("stores raw output outside the task and returns only a sanitized result", () => { + expect(create("job-valid").status).toBe(0); + const result = apply("job-valid", validResult("job-valid")); + expect(result.status).toBe(0); + const sanitized = JSON.parse(result.stdout) as { + audit: { raw_trace_stored: boolean }; + }; + expect(sanitized.audit.raw_trace_stored).toBe(true); + expect(sanitized).not.toHaveProperty("raw"); + const traceRoot = path.join(root, ".trellis", ".runtime", "hermes-traces"); + const rawFiles = fs.readdirSync(traceRoot, { recursive: true }) + .map(String) + .filter((name) => name.endsWith("job-valid.raw.jsonl")); + expect(rawFiles).toHaveLength(1); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.hermes_revision).toBe(5); + expect(task.status).toBe("in_progress"); + }); + + it("redacts github_pat tokens before any rejected result is persisted", () => { + expect(create("job-github-pat").status).toBe(0); + const token = `github_pat_${"A".repeat(24)}`; + const basicCredential = ["QWxhZGRp", "bjpvcGVu", "IHNlc2Ft", "ZQ=="].join(""); + const userPath = "/home/alice/private/research-notes.txt"; + const rejected = apply( + "job-github-pat", + validResult("job-github-pat", { + conclusion: `Do not persist ${token}; Authorization: Basic ${basicCredential}; ${userPath}`, + }), + ); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("sensitive_content"); + + const traceRoot = path.join(root, ".trellis", ".runtime", "hermes-traces"); + const traceFile = fs.readdirSync(traceRoot, { recursive: true }) + .map(String) + .find((name) => name.endsWith("job-github-pat.raw.jsonl")); + if (!traceFile) { + throw new Error("expected the rejected result trace to be recorded"); + } + const trace = fs.readFileSync(path.join(traceRoot, traceFile), "utf-8"); + expect(trace).toContain("[REDACTED]"); + expect(trace).toContain("[REDACTED_PATH]"); + expect(trace).not.toContain(token); + expect(trace).not.toContain(basicCredential); + expect(trace).not.toContain(userPath); + expect(fs.existsSync( + path.join(taskDir, "hermes", "dispatches", "job-github-pat.result.json"), + )).toBe(false); + }); + + it("does not accept runner success as evidence and requires an existing run ref", () => { + expect(create("job-runner", "runner").status).toBe(0); + writeJsonl(path.join(taskDir, "hermes", "run_manifest.jsonl"), [ + { id: "run-pass", exit_code: 0 }, + ]); + const withEvidence = apply( + "job-runner", + validResult("job-runner", { + changed_files: [], + evidence_refs: ["ev-chat"], + run_refs: ["run-pass"], + }), + ); + expect(withEvidence.stderr).toContain("runner_evidence_forbidden"); + }); + + it("rejects runner success when the referenced run failed", () => { + expect(create("job-runner-failed", "runner").status).toBe(0); + writeJsonl(path.join(taskDir, "hermes", "run_manifest.jsonl"), [ + { id: "run-failed", exit_code: 1, status: "failed" }, + ]); + const result = apply( + "job-runner-failed", + validResult("job-runner-failed", { + changed_files: [], + verification: { status: "failed", run_refs: ["run-failed"] }, + }), + ); + expect(result.stderr).toContain("failed_run_ref"); + }); + + it("keeps blind review off worker explanations and rejects chat close authority", () => { + fs.mkdirSync(path.join(taskDir, "hermes"), { recursive: true }); + fs.writeFileSync(path.join(taskDir, "hermes", "worker_records.jsonl"), "", "utf-8"); + const blind = run([ + "create", "--task", "demo", "--job-id", "job-blind", "--role", "reviewer", + "--profile", "evidence", "--objective", "Review current evidence", "--ref", + ".trellis/tasks/demo/hermes/worker_records.jsonl", + ]); + expect(blind.stderr).toContain("blind_review_ref"); + + expect(create("job-chat-close", "planner").status).toBe(0); + const closeAttempt = apply( + "job-chat-close", + validResult("job-chat-close", { + changed_files: [], + closure_state: "closed", + }), + ); + expect(closeAttempt.status).toBe(1); + expect(closeAttempt.stderr).toContain("unknown_result_fields"); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.closure_state).toBe("open"); + }); + + it("limits evidence and claim reviewers to proposed judgments", () => { + expect( + create("job-evidence-judgment", "reviewer", ["--profile", "evidence"]).status, + ).toBe(0); + const approved = apply( + "job-evidence-judgment", + validResult("job-evidence-judgment", { + changed_files: [], + review_judgment: { state: "approved", finding: "looks good" }, + }), + ); + expect(approved.stderr).toContain("review_authority_violation"); + + expect( + create("job-claim-judgment", "reviewer", ["--profile", "claim"]).status, + ).toBe(0); + const proposed = apply( + "job-claim-judgment", + validResult("job-claim-judgment", { + changed_files: [], + review_judgment: { state: "proposed", finding: "scope is bounded" }, + }), + ); + expect(proposed.status, proposed.stderr).toBe(0); + }); + + it("replaces a 50k Claude prompt with the validated canonical dispatch", () => { + expect(create("job-50k").status).toBe(0); + const result = runHook("inject-subagent-context.py", { + cwd: root, + hook_event_name: "PreToolUse", + tool_name: "Agent", + tool_input: { + subagent_type: "hermes-coder", + job_id: "job-50k", + prompt: `UNTRUSTED-${"x".repeat(50_000)}`, + }, + session_id: "firewall-session", + }); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + hookSpecificOutput: { updatedInput: { prompt: string } }; + }; + const prompt = String(payload.hookSpecificOutput.updatedInput.prompt); + expect(prompt).toContain("job_id: job-50k"); + expect(prompt).not.toContain("UNTRUSTED"); + expect(prompt.length).toBeLessThanOrEqual(2000); + }); + + it("denies Claude Hermes dispatch without a job, with stale state, or in async mode", () => { + const missing = runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "do work" }, + session_id: "firewall-session", + }); + expect(JSON.parse(missing.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + + 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; + writeJson(path.join(taskDir, "task.json"), task); + const stale = runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "job-hook-stale" }, + session_id: "firewall-session", + }); + expect(JSON.parse(stale.stdout).hookSpecificOutput.permissionDecisionReason).toContain( + "dispatch revision does not match", + ); + + writeJson(path.join(taskDir, "task.json"), baseTask()); + expect(create("job-async").status).toBe(0); + const asyncResult = runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { + subagent_type: "hermes-coder", + prompt: "job-async", + run_in_background: true, + }, + session_id: "firewall-session", + }); + expect(JSON.parse(asyncResult.stdout).hookSpecificOutput.permissionDecisionReason).toContain( + "must be synchronous", + ); + }); + + it("bounds SubagentStop rewrites and PostToolUse exposes only sanitized output", () => { + expect(create("job-hook-result").status).toBe(0); + const pre = runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "job-hook-result" }, + session_id: "firewall-session", + }); + expect(pre.status).toBe(0); + const started = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStart", + agent_id: "agent-hook-result", + agent_type: "hermes-coder", + session_id: "firewall-session", + }); + expect(started.stdout).toContain("job-hook-result"); + + const first = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStop", + agent_id: "agent-hook-result", + agent_type: "hermes-coder", + last_assistant_message: "not-json", + session_id: "firewall-session", + }); + expect(JSON.parse(first.stdout).decision).toBe("block"); + + const second = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStop", + agent_id: "agent-hook-result", + agent_type: "hermes-coder", + last_assistant_message: "still-not-json", + session_id: "firewall-session", + }); + expect(second.stdout).toBe(""); + + const post = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "PostToolUse", + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "job-hook-result" }, + tool_response: { + status: "completed", + agentId: "agent-hook-result", + content: [{ type: "text", text: "still-not-json" }], + }, + session_id: "firewall-session", + }); + const output = JSON.parse(post.stdout).hookSpecificOutput.updatedToolOutput as { + content: { text: string }[]; + }; + expect(output.content[0].text).toContain('"status":"blocked"'); + expect(output.content[0].text).not.toContain("still-not-json"); + }); + + it("validates a Claude final result and replaces Agent output with its summary", () => { + expect(create("job-hook-valid").status).toBe(0); + runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "job-hook-valid" }, + session_id: "firewall-session", + }); + const started = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStart", + agent_id: "agent-hook-valid", + agent_type: "hermes-coder", + session_id: "firewall-session", + }); + expect(started.stdout).toContain("job-hook-valid"); + const raw = JSON.stringify(validResult("job-hook-valid")); + const stopped = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStop", + agent_id: "agent-hook-valid", + agent_type: "hermes-coder", + last_assistant_message: raw, + session_id: "firewall-session", + }); + expect(stopped.stdout).toBe(""); + const post = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "PostToolUse", + tool_name: "Agent", + tool_input: { subagent_type: "hermes-coder", prompt: "job-hook-valid" }, + tool_response: { + status: "completed", + agentId: "agent-hook-valid", + content: [{ type: "text", text: `${raw}\nRAW_SHOULD_NOT_RETURN` }], + }, + session_id: "firewall-session", + }); + const output = JSON.parse(post.stdout).hookSpecificOutput.updatedToolOutput as { + content: { text: string }[]; + }; + expect(output.content[0].text).toContain("Bounded work completed"); + expect(output.content[0].text).not.toContain("RAW_SHOULD_NOT_RETURN"); + expect(output.content[0].text).not.toContain("audit"); + }); + + it("binds blind reviewer reads to allowed refs and rejects worker context", () => { + expect(create("job-review-read", "reviewer", ["--profile", "quality"]).status).toBe(0); + runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-reviewer", prompt: "job-review-read" }, + session_id: "firewall-session", + }); + runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "SubagentStart", + agent_id: "agent-review-read", + agent_type: "hermes-reviewer", + session_id: "firewall-session", + }); + const allowed = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: path.join(taskDir, "prd.md") }, + agent_id: "agent-review-read", + agent_type: "hermes-reviewer", + session_id: "firewall-session", + }); + expect(allowed.stdout).toBe(""); + const denied = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "PreToolUse", + tool_name: "Read", + tool_input: { file_path: path.join(taskDir, "hermes", "worker_records.jsonl") }, + agent_id: "agent-review-read", + agent_type: "hermes-reviewer", + session_id: "firewall-session", + }); + expect(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + }); + + it("leaves legacy non-Hermes Trellis Agent behavior outside the bridge", () => { + writeJson(path.join(taskDir, "task.json"), { + id: "demo", + title: "Legacy", + status: "in_progress", + }); + const result = runHook("inject-subagent-context.py", { + cwd: root, + tool_name: "Agent", + tool_input: { subagent_type: "hermes-evaluator", prompt: "legacy-evidence" }, + session_id: "firewall-session", + }); + expect(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision).toBe("deny"); + const guard = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { file_path: "src/app.ts" }, + session_id: "firewall-session", + }); + expect(guard.stdout).toBe(""); + }); + + it("runs Codex strict through output-schema/json/o and stores only sanitized output", () => { + expect(create("job-codex-strict").status).toBe(0); + const fakeCodex = path.join(root, "fake-codex.py"); + fs.writeFileSync( + fakeCodex, + [ + "#!/usr/bin/env python3", + "import json, pathlib, re, sys", + "args = sys.argv[1:]", + "if args == ['exec', '--help']:", + " print('codex exec --output-schema --json -o')", + " raise SystemExit(0)", + "output = pathlib.Path(args[args.index('-o') + 1])", + "prompt = args[-1]", + "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", + "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", + "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", + "pathlib.Path('src/app.ts').write_text('strict change\\n', encoding='utf-8')", + "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Strict result.', 'uncertainties': [], 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review package.'}), encoding='utf-8')", + "print(json.dumps({'type': 'trace', 'private': 'raw codex trace'}))", + "", + ].join("\n"), + "utf-8", + ); + fs.chmodSync(fakeCodex, 0o755); + const result = run([ + "run", "--task", "demo", "--job-id", "job-codex-strict", + "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, + ]); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Strict result."); + expect(result.stdout).not.toContain("raw codex trace"); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-codex-strict.dispatch.json"), + "utf-8", + ), + ); + expect(dispatch.execution_mode).toBe("strict"); + expect(dispatch.status).toBe("confirmed"); + }); + + it("excludes environment and credential files from the Codex strict workspace", () => { + expect(create("job-codex-secret-workspace").status).toBe(0); + fs.writeFileSync(path.join(root, ".env"), "SECRET_VALUE=do-not-copy\n", "utf-8"); + fs.writeFileSync(path.join(root, ".npmrc"), "//registry.example/:_authToken=do-not-copy\n", "utf-8"); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.writeFileSync( + path.join(root, "config", "service-account.json"), + `token=${["github_pat_", "B".repeat(24)].join("")}\n`, + "utf-8", + ); + const fakeCodex = path.join(root, "fake-codex-secret-workspace.py"); + fs.writeFileSync( + fakeCodex, + [ + "#!/usr/bin/env python3", + "import json, pathlib, re, sys", + "args = sys.argv[1:]", + "if args == ['exec', '--help']:", + " print('codex exec --output-schema --json -o')", + " raise SystemExit(0)", + "if pathlib.Path('.env').exists() or pathlib.Path('.npmrc').exists() or pathlib.Path('config/service-account.json').exists():", + " raise SystemExit('sensitive file copied into strict workspace')", + "output = pathlib.Path(args[args.index('-o') + 1])", + "prompt = args[-1]", + "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", + "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", + "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", + "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Sensitive files were excluded.', 'uncertainties': [], 'changed_files': [], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", + "", + ].join("\n"), + "utf-8", + ); + fs.chmodSync(fakeCodex, 0o755); + + const result = run([ + "run", "--task", "demo", "--job-id", "job-codex-secret-workspace", + "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, + ]); + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(path.join(root, ".env"))).toBe(true); + expect(fs.existsSync(path.join(root, ".npmrc"))).toBe(true); + expect(fs.existsSync(path.join(root, "config", "service-account.json"))).toBe(true); + }); + + it("rejects Codex strict when actual changes escape allowed files", () => { + expect(create("job-codex-escape").status).toBe(0); + fs.writeFileSync(path.join(root, "README.md"), "original\n", "utf-8"); + const originalReadme = fs.readFileSync(path.join(root, "README.md"), "utf-8"); + const fakeCodex = path.join(root, "fake-codex-escape.py"); + fs.writeFileSync( + fakeCodex, + [ + "#!/usr/bin/env python3", + "import json, pathlib, re, sys", + "args = sys.argv[1:]", + "if args == ['exec', '--help']:", + " print('codex exec --output-schema --json -o')", + " raise SystemExit(0)", + "output = pathlib.Path(args[args.index('-o') + 1])", + "prompt = args[-1]", + "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", + "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", + "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", + "pathlib.Path('README.md').write_text('unauthorized\\n', encoding='utf-8')", + "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Escaped.', 'uncertainties': [], 'changed_files': ['README.md'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", + ].join("\n"), + "utf-8", + ); + fs.chmodSync(fakeCodex, 0o755); + const result = run([ + "run", "--task", "demo", "--job-id", "job-codex-escape", + "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("unauthorized_actual_changes"); + expect(fs.readFileSync(path.join(root, "README.md"), "utf-8")).toBe(originalReadme); + }); + + it("does not sync strict workspace changes when the result envelope is invalid", () => { + expect(create("job-codex-invalid").status).toBe(0); + const originalSource = fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8"); + const fakeCodex = path.join(root, "fake-codex-invalid.py"); + fs.writeFileSync( + fakeCodex, + [ + "#!/usr/bin/env python3", + "import json, pathlib, re, sys", + "args = sys.argv[1:]", + "if args == ['exec', '--help']:", + " print('codex exec --output-schema --json -o')", + " raise SystemExit(0)", + "output = pathlib.Path(args[args.index('-o') + 1])", + "prompt = args[-1]", + "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", + "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", + "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", + "pathlib.Path('src/app.ts').write_text('untrusted strict change\\n', encoding='utf-8')", + "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Missing required uncertainties.', 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", + "", + ].join("\n"), + "utf-8", + ); + fs.chmodSync(fakeCodex, 0o755); + + const result = run([ + "run", "--task", "demo", "--job-id", "job-codex-invalid", + "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("missing_result_fields"); + expect(fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8")).toBe( + originalSource, + ); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-codex-invalid.dispatch.json"), + "utf-8", + ), + ) as { status: string; invalid_result_attempts: number; last_result_error: string }; + expect(dispatch).toMatchObject({ + status: "rewrite_required", + invalid_result_attempts: 1, + last_result_error: "missing_result_fields", + }); + }); + + it("degrades missing Codex strict capability by closure mode", () => { + expect(create("job-codex-lean").status).toBe(0); + const lean = run([ + "run", "--task", "demo", "--job-id", "job-codex-lean", + "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", + ]); + expect(lean.status).toBe(0); + expect(lean.stdout).toContain('"status": "advisory"'); + + writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); + expect(create("job-codex-publication", "planner").status).toBe(0); + const publication = run([ + "run", "--task", "demo", "--job-id", "job-codex-publication", + "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", + ]); + expect(publication.status).toBe(1); + expect(publication.stderr).toContain("codex_strict_unavailable"); + }); + + it("does not accept environment flags or unsigned future heartbeats as a hard gate", () => { + writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "standard" })); + expect(create("job-forged", "planner", ["--platform", "codex"]).status).toBe(0); + const heartbeatDir = path.join(root, ".trellis", ".runtime", "context-firewall"); + fs.mkdirSync(heartbeatDir, { recursive: true }); + writeJson(path.join(heartbeatDir, "codex-hooks-forged.json"), { + platform: "codex", + mechanism: "hooks", + task_id: "demo", + job_id: "job-forged", + timestamp: "2999-01-01T00:00:00Z", + }); + const forged = run( + [ + "run", "--task", "demo", "--job-id", "job-forged", + "--platform", "codex", "--mode", "native", + ], + { TRELLIS_HOOKS_ACTIVE: "1", TRELLIS_CODEX_STRICT: "1" }, + ); + expect(forged.status).toBe(1); + expect(forged.stderr).toContain("firewall_unavailable"); + }); + + it("supersedes a blocked dispatch and restores the package mechanically", () => { + expect(create("job-old").status).toBe(0); + expect(apply("job-old", {}).status).toBe(1); + expect(apply("job-old", {}).status).toBe(1); + expect(create("job-replacement").status).toBe(0); + const superseded = run([ + "supersede", "--task", "demo", "--job-id", "job-old", + "--replacement-job-id", "job-replacement", "--reason", "replace invalid output", + ]); + expect(superseded.status, superseded.stderr).toBe(0); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + expect(task.work_packages[0].status).toBe("running"); + expect(task.work_packages[0].dispatch_blockers).toEqual([]); + const oldDispatch = JSON.parse( + fs.readFileSync(path.join(taskDir, "hermes", "dispatches", "job-old.dispatch.json"), "utf-8"), + ); + const replacement = JSON.parse( + 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); + }); + + it("blocks Stop for unconfirmed results, undisposed packages, and pending close", () => { + expect(create("job-stop-gate", "planner").status).toBe(0); + const unconfirmed = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "Stop", + session_id: "firewall-session", + }); + expect(JSON.parse(unconfirmed.stdout).reason).toContain("still unconfirmed"); + + expect( + apply( + "job-stop-gate", + validResult("job-stop-gate", { changed_files: [] }), + ).status, + ).toBe(0); + const undisposed = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "Stop", + session_id: "firewall-session", + }); + expect(JSON.parse(undisposed.stdout).reason).toContain("remain undisposed"); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.work_packages[0].status = "done"; + task.current_work_package = null; + task.hermes_phase = "review"; + writeJson(path.join(taskDir, "task.json"), task); + const pendingClose = runHook("hermes-runtime-guard.py", { + cwd: root, + hook_event_name: "Stop", + session_id: "firewall-session", + }); + expect(JSON.parse(pendingClose.stdout).reason).toContain("audit/close"); + expect(JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")).status).toBe( + "in_progress", + ); + }); +}); diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index b05a081..ca2ad32 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -27,6 +27,10 @@ interface TestTask { max_repair_count: number; blockers: string[]; meta: { research_contract?: { dataset?: string } }; + hermes_revision: number; + research_route?: string; + research_change_fields?: string[]; + grill_completed?: boolean; } function hasPython(): boolean { @@ -66,6 +70,7 @@ function baseTask(overrides: Record = {}) { blockers: [], repair_count: 0, max_repair_count: 1, + hermes_revision: 0, ...overrides, }; } @@ -92,7 +97,15 @@ function run(root: string, ...args: string[]) { return spawnSync( PYTHON, [".trellis/scripts/closure.py", ...args, "--task", "demo"], - { cwd: root, encoding: "utf-8" }, + { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, ); } @@ -104,6 +117,35 @@ function runTask(root: string, ...args: string[]) { }); } +function recordHookHeartbeat(root: string, taskId = "demo"): void { + const result = spawnSync( + PYTHON, + [ + "-c", + "import sys; from pathlib import Path; sys.path.insert(0, '.trellis/scripts'); from common.firewall import record_firewall_heartbeat; raise SystemExit(0 if record_firewall_heartbeat(Path('.').resolve(), 'claude', 'hooks', task_id=sys.argv[1], session_id='test-session') else 1)", + taskId, + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(result.status, result.stderr).toBe(0); +} + +function runDispatch(root: string, ...args: string[]) { + return spawnSync( + PYTHON, + [".trellis/scripts/hermes/dispatch.py", ...args, "--task", "demo"], + { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); +} + function readTask(taskDir: string): TestTask { return JSON.parse( fs.readFileSync(path.join(taskDir, "task.json"), "utf-8"), @@ -124,10 +166,29 @@ function plan(root: string, doneWhen: string[], extra: string[] = []) { return run(root, ...args); } -function completeCurrent(root: string, evidence = "test:pass"): void { +function planTwoExplicitPackages(root: string) { + return plan(root, ["Result A", "Result B"], [ + "--package", + "Prepare result A::Result A::Result A", + "--package", + "Prepare result B::Result B::Result B", + ]); +} + +function materializeEvidence(root: string, label: string): string { + const safeName = label.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "result"; + const relativePath = `artifacts/${safeName}.txt`; + const absolutePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, `validation evidence: ${label}\n`, "utf-8"); + return relativePath; +} + +function completeCurrent(root: string, evidence = "test-pass"): void { + const evidenceRef = materializeEvidence(root, evidence); expect(run(root, "package-start").status).toBe(0); expect(run(root, "package-check").status).toBe(0); - expect(run(root, "package-done", "--evidence", evidence).status).toBe(0); + expect(run(root, "package-done", "--evidence", evidenceRef).status).toBe(0); } function appendJsonl( @@ -161,7 +222,149 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).work_packages).toHaveLength(1); }); - it("plans two to four packages for ordinary observable outcomes", () => { + it("requires a completed grill before an exploration task validates", () => { + expect( + plan(root, ["Research result is verified"], [ + "--route", + "exploration", + "--research-change", + "model_architecture", + ]).status, + ).toBe(0); + const before = readTask(taskDir); + expect(before.research_route).toBe("exploration"); + expect(before.research_change_fields).toEqual(["model_architecture"]); + expect(before.grill_completed).toBe(false); + const blocked = run(root, "validate"); + expect(blocked.status).toBe(1); + expect(blocked.stderr).toContain("completed grill"); + + expect(run(root, "grill", "--complete").status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const capsule = run(root, "capsule"); + expect(capsule.stdout).toContain("Route: exploration"); + expect(capsule.stdout).toContain("Research changes: model_architecture"); + expect(capsule.stdout).toContain("Grill: complete"); + }); + + it("automatically routes recorded research changes to exploration", () => { + expect( + plan(root, ["Research result is verified"], [ + "--research-change", + "dataset", + ]).status, + ).toBe(0); + expect(readTask(taskDir).research_route).toBe("exploration"); + expect(run(root, "validate").status).toBe(1); + + const conflict = run(root, "route", "--route", "delivery"); + expect(conflict.status).toBe(1); + expect(conflict.stderr).toContain("require the exploration route"); + }); + + it("rejects an explicit delivery route with research protocol changes", () => { + const result = plan(root, ["Research result is verified"], [ + "--route", + "delivery", + "--research-change", + "dataset", + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("require the exploration route"); + expect(readTask(taskDir).research_route).toBeUndefined(); + }); + + it("allows a deterministic delivery task to validate without a grill", () => { + expect( + plan(root, ["Delivery is verified"], ["--route", "delivery"]).status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(readTask(taskDir).research_route).toBe("delivery"); + }); + + it("keeps older Hermes closure tasks compatible with the delivery default", () => { + writeTask(taskDir, baseTask({ + intent: "Preserve an existing closure task", + definition_of_done: ["Existing result is verified"], + work_packages: [ + { + id: "WP1", + title: "Existing result", + outcome: "Existing result is verified", + done_when: ["Existing result is verified"], + evidence_required: [], + depends_on: [], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ], + })); + expect(run(root, "validate").status).toBe(0); + const task = readTask(taskDir); + expect(task.research_route).toBe("delivery"); + expect(task.grill_completed).toBe(false); + }); + + it("increments hermes_revision on every Closure semantic write", () => { + expect(readTask(taskDir).hermes_revision).toBe(0); + expect(plan(root, ["CLI prints a verified result"]).status).toBe(0); + expect(readTask(taskDir).hermes_revision).toBe(1); + expect(run(root, "validate").status).toBe(0); + expect(readTask(taskDir).hermes_revision).toBe(2); + expect(run(root, "package-start").status).toBe(0); + expect(readTask(taskDir).hermes_revision).toBe(3); + expect(run(root, "package-check").status).toBe(0); + expect(readTask(taskDir).hermes_revision).toBe(4); + const evidence = materializeEvidence(root, "revision"); + expect(run(root, "package-done", "--evidence", evidence).status).toBe(0); + expect(readTask(taskDir).hermes_revision).toBe(5); + }); + + it("rejects a stale task writer with compare-and-swap semantics", () => { + const script = [ + "import copy, sys", + "from pathlib import Path", + "sys.path.insert(0, str(Path(sys.argv[1]).resolve()))", + "from common.closure import ClosureError, save_task", + "from common.io import read_json", + "task_dir = Path(sys.argv[2])", + "first = read_json(task_dir / 'task.json')", + "second = copy.deepcopy(first)", + "save_task(task_dir, first)", + "try:", + " save_task(task_dir, second)", + "except ClosureError as exc:", + " raise SystemExit(0 if 'stale task revision' in str(exc) else 2)", + "raise SystemExit(1)", + ].join("\n"); + const result = spawnSync( + PYTHON, + ["-c", script, path.join(root, ".trellis", "scripts"), taskDir], + { cwd: root, encoding: "utf-8" }, + ); + expect(result.status, result.stderr).toBe(0); + }); + + it("plans explicit work packages from implementation units", () => { + fs.writeFileSync( + path.join(taskDir, "implement.md"), + [ + "# Implementation", + "", + "## Work Packages", + "### WP1: Prepare dataset", + "Outcome: Dataset is prepared", + "Done when: Dataset is prepared", + "### WP2: Evaluate model", + "Outcome: Model is evaluated", + "Done when: Model is evaluated", + "### WP3: Review report", + "Outcome: Report is reviewed", + "Done when: Report is reviewed", + "", + ].join("\n"), + ); expect( plan(root, [ "Dataset is prepared", @@ -172,6 +375,23 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).work_packages).toHaveLength(3); }); + it("uses one safe package when implementation units are absent", () => { + const result = plan(root, ["Dataset is prepared", "Model is evaluated"]); + expect(result.status).toBe(0); + expect(result.stderr).toContain("one safe work package"); + expect(readTask(taskDir).work_packages).toHaveLength(1); + }); + + it("keeps context pins first in the compact capsule", () => { + expect( + plan(root, ["Artifact exists"], ["--context-pin", ".trellis/spec/guides/index.md"]) + .status, + ).toBe(0); + const capsule = run(root, "capsule"); + expect(capsule.status).toBe(0); + expect(capsule.stdout).toContain("Refs: .trellis/spec/guides/index.md"); + }); + it("keeps command and file microsteps inside done_when", () => { expect( plan(root, [ @@ -280,8 +500,30 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(result.stderr).toContain("requires validation evidence"); }); + it("rejects unresolvable evidence refs and catches forged refs during audit", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + expect(run(root, "package-check").status).toBe(0); + const rejected = run(root, "package-done", "--evidence", "test:pass"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("existing repository files or evidence ledger ids"); + + const task = readTask(taskDir) as TestTask & { + work_packages: (TestWorkPackage & { evidence_refs: string[] })[]; + }; + task.work_packages[0].status = "done"; + task.work_packages[0].evidence_refs = ["forged:reference"]; + task.current_work_package = null; + task.hermes_phase = "review"; + writeTask(taskDir, task); + const audit = run(root, "audit", "--no-report"); + expect(audit.status).toBe(1); + expect(audit.stdout).toContain("invalid evidence refs"); + }); + it("does not dispose another package while one is current", () => { - expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(planTwoExplicitPackages(root).status).toBe(0); expect(run(root, "validate").status).toBe(0); expect(run(root, "package-start").status).toBe(0); const result = run( @@ -299,7 +541,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); it("starts the next package after completing the previous package", () => { - expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(planTwoExplicitPackages(root).status).toBe(0); expect(run(root, "validate").status).toBe(0); completeCurrent(root, "test:a"); @@ -316,7 +558,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); it("refuses close while part of the plan is unfinished", () => { - expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(planTwoExplicitPackages(root).status).toBe(0); expect(run(root, "validate").status).toBe(0); completeCurrent(root); const result = run(root, "close"); @@ -334,6 +576,30 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(events).not.toContain('"event_type":"task_closed"'); }); + it("refuses close while a dispatch result is still unconfirmed", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root); + expect( + runDispatch( + root, + "create", + "--job-id", + "job-unconfirmed", + "--role", + "reviewer", + "--profile", + "closure", + "--objective", + "Review closure readiness", + ).status, + ).toBe(0); + const result = run(root, "close"); + expect(result.status).toBe(1); + expect(result.stdout).toContain("unconfirmed dispatch job-unconfirmed"); + expect(readTask(taskDir).closure_state).toBe("open"); + }); + it("refuses archive before closure", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -368,8 +634,24 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(result.stdout.length).toBeLessThan(800); }); + it("strongly warns when lean closure runs without hooks or strict heartbeat", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const result = spawnSync( + PYTHON, + [".trellis/scripts/closure.py", "audit", "--task", "demo", "--no-report"], + { + cwd: root, + encoding: "utf-8", + env: { ...process.env, TRELLIS_HOOKS: "0" }, + }, + ); + expect(result.stdout).toContain("warnings:"); + expect(result.stdout).toContain("advisory"); + }); + it("repairs only the first gap and preserves completed packages", () => { - expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(planTwoExplicitPackages(root).status).toBe(0); expect(run(root, "validate").status).toBe(0); completeCurrent(root, "test:a"); const before = structuredClone(readTask(taskDir).work_packages[0]); @@ -383,13 +665,13 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); it("runs audit gap, one repair, and final close end to end", () => { - expect(plan(root, ["Result A", "Result B"]).status).toBe(0); + expect(planTwoExplicitPackages(root).status).toBe(0); expect(run(root, "validate").status).toBe(0); completeCurrent(root, "test:a"); expect(run(root, "audit", "--no-report").status).toBe(1); expect(run(root, "repair").status).toBe(1); expect(run(root, "package-check").status).toBe(0); - expect(run(root, "package-done", "--evidence", "test:b").status).toBe(0); + expect(run(root, "package-done", "--evidence", materializeEvidence(root, "test-b")).status).toBe(0); expect(run(root, "audit").status).toBe(0); expect(run(root, "close").status).toBe(0); expect(readTask(taskDir).closure_state).toBe("closed"); @@ -479,6 +761,61 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.meta.research_contract.dataset).toBe("dataset-v2"); expect(task.hermes_phase).toBe("planning"); expect(task.blockers).toEqual([]); + expect(task.research_route).toBe("exploration"); + expect(task.research_change_fields).toEqual(["dataset"]); + expect(task.grill_completed).toBe(false); + expect(run(root, "validate").status).toBe(1); + expect(run(root, "grill", "--complete").status).toBe(0); + expect(run(root, "validate").status).toBe(0); + }); + + it("treats functional model architecture changes as high risk", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const denied = run( + root, + "amend", + "--field", + "model_architecture", + "--value", + "new functional encoder", + "--reason", + "new model behavior", + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain( + "high-risk change model_architecture requires --approved-by human/root", + ); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + }); + + it("blocks an approved model architecture amendment and requires a new exploration task", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const result = run( + root, + "amend", + "--field", + "model_architecture", + "--value", + "new functional encoder", + "--reason", + "new model behavior", + "--approved-by", + "human/root", + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("independent exploration task"); + const task = readTask(taskDir); + expect(task.hermes_phase).toBe("blocked"); + expect(task.status).toBe("in_progress"); + expect(task.research_route).toBe("exploration"); + expect(task.research_change_fields).toEqual(["model_architecture"]); + expect(task.grill_completed).toBe(false); + expect( + (task.meta.research_contract as Record | undefined) + ?.model_architecture, + ).toBeUndefined(); }); it("does not allow amend to bypass task state commands", () => { @@ -524,6 +861,48 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { ).toBe(true); }); + it("refuses archive when closure fields are forged without a close event", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + const evidence = materializeEvidence(root, "forged-archive"); + const forged = readTask(taskDir) as TestTask & { + work_packages: (TestWorkPackage & { evidence_refs: string[] })[]; + }; + forged.status = "completed"; + forged.hermes_phase = "closed"; + forged.closure_state = "closed"; + forged.current_work_package = null; + forged.work_packages[0].status = "done"; + forged.work_packages[0].evidence_refs = [evidence]; + writeTask(taskDir, forged); + + const archive = runTask(root, "archive", "demo", "--no-commit"); + expect(archive.status).toBe(1); + expect(archive.stderr).toContain("task_closed event is missing"); + expect(fs.existsSync(taskDir)).toBe(true); + }); + + it("rolls back task state when recording the close event fails", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "pnpm test:pass"); + + const beforeClose = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + fs.rmSync(eventPath); + fs.mkdirSync(eventPath); + + const result = run(root, "close"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("failed to read task event log"); + + const afterFailure = readTask(taskDir); + expect(afterFailure.status).toBe(beforeClose.status); + expect(afterFailure.hermes_phase).toBe(beforeClose.hermes_phase); + expect(afterFailure.closure_state).toBe(beforeClose.closure_state); + expect(afterFailure.current_work_package).toBe(beforeClose.current_work_package); + expect(afterFailure.hermes_revision).toBe(beforeClose.hermes_revision); + }); + it("closes standard mode when run, artifact, metrics, and evidence exist", () => { expect( plan(root, ["Standard result verified"], ["--mode", "standard"]).status, @@ -540,6 +919,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { appendJsonl(taskDir, "claim_ledger.jsonl", [ { id: "cl-standard", limits: "fixed fixture only" }, ]); + recordHookHeartbeat(root); expect(run(root, "close").status).toBe(0); expect(readTask(taskDir).closure_state).toBe("closed"); }); @@ -578,6 +958,34 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).closure_state).toBe("open"); }); + it("does not close publication mode when hooks are explicitly disabled", () => { + expect( + plan(root, ["Publication result verified"], ["--mode", "publication"]) + .status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "ev-1"); + const result = spawnSync( + PYTHON, + [".trellis/scripts/closure.py", "close", "--task", "demo"], + { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS: "0", + TRELLIS_HOOKS_ACTIVE: "1", + }, + }, + ); + expect(result.status).toBe(1); + expect(result.stdout).toContain("context firewall"); + const task = readTask(taskDir); + expect(task.status).toBe("in_progress"); + expect(task.closure_state).toBe("open"); + expect(task.hermes_phase).not.toBe("closed"); + }); + it("keeps legacy non-Hermes archive behavior", () => { const legacyRoot = fs.mkdtempSync( path.join(os.tmpdir(), "trellis-legacy-"), diff --git a/packages/cli/test/templates/claude.test.ts b/packages/cli/test/templates/claude.test.ts index c0407c4..95e6556 100644 --- a/packages/cli/test/templates/claude.test.ts +++ b/packages/cli/test/templates/claude.test.ts @@ -102,6 +102,23 @@ describe("settingsTemplate SessionStart matchers", () => { expect(entry?.matcher).toBe("Edit|Write|MultiEdit|Bash|apply_patch"); expect(entry?.hooks[0].timeout).toBeGreaterThanOrEqual(15); }); + + it("replaces Agent tool output through the Hermes PostToolUse firewall", () => { + const settings = JSON.parse(settingsTemplate) as { + hooks: { + PostToolUse?: { + matcher?: string; + hooks: { command: string; timeout: number }[]; + }[]; + }; + }; + const entry = settings.hooks.PostToolUse?.find( + (candidate) => candidate.matcher === "Agent", + ); + expect(entry).toBeDefined(); + expect(entry?.hooks[0].command).toContain("hermes-runtime-guard.py"); + expect(entry?.hooks[0].timeout).toBeGreaterThanOrEqual(30); + }); }); // Commands are now sourced from common/ templates and tested in platforms.test.ts @@ -133,63 +150,30 @@ describe("getAllAgents", () => { ); }); - it("includes Hermes research agents that load global config and task records", () => { + it("includes exactly five canonical Hermes role templates", () => { const agents = new Map( getAllAgents().map((agent) => [agent.name, agent.content]), ); - - expect([...agents.keys()]).toEqual( - expect.arrayContaining(["hermes-scientist", "hermes-claim-reviewer"]), - ); - - for (const name of ["hermes-scientist", "hermes-claim-reviewer"]) { - const content = agents.get(name); - expect(content).toBeDefined(); - expect(content).toContain(".trellis/hermes/config.yaml"); - expect(content).toContain(".trellis/hermes/state_machine.yaml"); - expect(content).toContain(".trellis/tasks//hermes/"); - expect(content).toContain("append-only"); - } - - expect(agents.get("hermes-claim-reviewer")).toContain("claim_ready"); - expect(agents.get("hermes-claim-reviewer")).toContain("approved"); - expect(agents.get("hermes-claim-reviewer")).toContain( - "human approval record", - ); - }); - - it("includes the expanded Hermes worker roster and task-card discipline", () => { - const agents = new Map( - getAllAgents().map((agent) => [agent.name, agent.content]), - ); - - expect([...agents.keys()]).toEqual( - expect.arrayContaining([ - "hermes-scientist", - "hermes-claim-reviewer", - "hermes-coder", - "hermes-runner", - "hermes-evaluator", - "hermes-reviewer", - "hermes-literature", - ]), - ); - - for (const name of [ - "hermes-scientist", - "hermes-claim-reviewer", + const canonical = [ + "hermes-planner", + "hermes-researcher", "hermes-coder", "hermes-runner", - "hermes-evaluator", "hermes-reviewer", - "hermes-literature", - ]) { + ]; + expect([...agents.keys()].filter((name) => name.startsWith("hermes-"))).toEqual( + canonical.sort(), + ); + for (const name of canonical) { const content = agents.get(name); expect(content).toBeDefined(); - expect(content).toContain(".trellis/hermes/roles/"); - expect(content).toContain("worker_records.jsonl"); - expect(content).toContain("task card"); - expect(content).toContain("HumanGate"); + expect(content).toMatch(/validated (?:blind-review )?dispatch body/); + expect(content).toMatch(/Profile|profile/); + expect(content).toMatch(/Do not spawn another sub-agent/i); + expect(content).toContain("Result Envelope JSON"); + expect(content).toContain("uncertainties"); + expect(content).not.toContain(".trellis/hermes/state_machine.yaml"); + expect(content).not.toContain(".trellis/tasks//prd.md"); } }); @@ -202,7 +186,8 @@ describe("getAllAgents", () => { expect(files).toBeDefined(); const skill = files?.find((file) => file.relativePath === "SKILL.md"); expect(skill?.content).toContain("name: hermes-research"); - expect(skill?.content).toContain(".trellis/hermes/config.yaml"); + expect(skill?.content).toContain("Pass only its `job_id` to Claude Agent"); + expect(skill?.content).toContain("at most three direct references"); expect(skill?.content).toContain(".trellis/hermes/state_machine.yaml"); expect(skill?.content).toContain(".trellis/hermes/records/recordbus.md"); expect(skill?.content).toContain( diff --git a/packages/cli/test/templates/codex.test.ts b/packages/cli/test/templates/codex.test.ts index f520b1e..9912f08 100644 --- a/packages/cli/test/templates/codex.test.ts +++ b/packages/cli/test/templates/codex.test.ts @@ -1,5 +1,7 @@ +import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -13,8 +15,18 @@ import { AI_TOOLS } from "../../src/types/ai-tools.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../../.."); +const PYTHON = process.platform === "win32" ? "python" : "python3"; +const TEMPLATE_SCRIPTS = path.resolve( + __dirname, + "../../src/templates/trellis/scripts", +); const EXPECTED_AGENT_NAMES = [ + "hermes-coder", + "hermes-planner", + "hermes-researcher", + "hermes-reviewer", + "hermes-runner", "trellis-check", "trellis-implement", "trellis-research", @@ -56,6 +68,20 @@ describe("codex getAllAgents", () => { expect(agent.content).toContain("developer_instructions = "); } }); + + it("marks native Hermes agents advisory and points enforced work to strict dispatch", () => { + const agents = new Map(getAllAgents().map((agent) => [agent.name, agent.content])); + for (const role of ["planner", "researcher", "coder", "runner", "reviewer"]) { + const content = agents.get(`hermes-${role}`); + expect(content).toContain("Native Codex dispatch is advisory"); + expect(content).toContain(`role: ${role}:`); + expect(content).toMatch(/Profile|profile/); + expect(content).toContain("multi_agent = false"); + expect(content).toContain("--mode strict"); + expect(content).toContain("Result Envelope JSON"); + expect(content).not.toContain(".trellis/tasks//prd.md"); + } + }); }); describe("codex getAllCodexSkills (platform-specific)", () => { @@ -71,6 +97,8 @@ describe("codex getConfigTemplate", () => { expect(config.targetPath).toBe("config.toml"); expect(config.content).toContain("project_doc_fallback_filenames"); expect(config.content).toContain("AGENTS.md"); + expect(config.content).toContain('native_authority = "advisory"'); + expect(config.content).toContain("--output-schema --json -o"); }); }); @@ -159,7 +187,7 @@ describe("codex session-start.py compact SessionStart context", () => { expect(content).toContain( ".trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md", ); - expect(content).toContain("minimal_file_context"); + expect(content).toContain("validated_dispatch_only"); expect(content).toContain("design.md if present"); expect(content).not.toContain(""); expect(content).not.toContain("guides (inlined"); @@ -167,4 +195,68 @@ describe("codex session-start.py compact SessionStart context", () => { "Project spec indexes are listed by path below", ); }); + + it("shows the closure capsule instead of legacy planning guidance", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-closure-")); + try { + const taskDir = path.join(root, ".trellis", "tasks", "demo"); + fs.mkdirSync(taskDir, { recursive: true }); + fs.cpSync(TEMPLATE_SCRIPTS, path.join(root, ".trellis", "scripts"), { + recursive: true, + }); + fs.mkdirSync(path.join(root, ".trellis", ".runtime", "sessions"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, ".trellis", ".runtime", "sessions", "codex-demo.json"), + '{"current_task":".trellis/tasks/demo"}\n', + ); + fs.writeFileSync( + path.join(taskDir, "task.json"), + `${JSON.stringify({ + id: "demo", + title: "Closure demo", + status: "planning", + hermes_phase: "ready", + closure_state: "open", + closure_mode: "lean", + intent: "Verify the active work package", + in_scope: ["demo"], + out_of_scope: [], + definition_of_done: ["Focused validation passes"], + work_packages: [ + { + id: "WP1", + title: "Verify result", + outcome: "Focused validation passes", + done_when: ["Focused validation passes"], + evidence_required: [], + depends_on: [], + status: "ready", + evidence_refs: [], + blocker: null, + }, + ], + current_work_package: null, + next_action: "Run closure.py package-start.", + blockers: [], + repair_count: 0, + max_repair_count: 1, + hermes_revision: 2, + }, null, 2)}\n`, + ); + const result = spawnSync(PYTHON, [hookPath], { + cwd: root, + encoding: "utf-8", + input: JSON.stringify({ cwd: root }), + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Status: HERMES CLOSURE"); + expect(result.stdout).toContain("Mode/Phase: lean / ready"); + expect(result.stdout).toContain("Start WP1 with closure.py package-start."); + expect(result.stdout).not.toContain("Status: PLANNING"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/test/templates/hermes-roles.test.ts b/packages/cli/test/templates/hermes-roles.test.ts new file mode 100644 index 0000000..23ca7cf --- /dev/null +++ b/packages/cli/test/templates/hermes-roles.test.ts @@ -0,0 +1,269 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const TEMPLATE_SCRIPTS = path.resolve( + __dirname, + "../../src/templates/trellis/scripts", +); + +function taskCard( + role: string, + options: { profile?: string; objective?: string; jobId?: string } = {}, +) { + return { + type: "task_card", + id: `tc-${options.jobId ?? role}`, + timestamp: "2026-07-16T00:00:00Z", + job_id: options.jobId ?? `job-${role}`, + role, + ...(options.profile ? { profile: options.profile } : {}), + ...(options.objective ? { objective: options.objective } : {}), + worktree_id: "main", + status: "queued", + allowed_files: ["src/**"], + forbidden_files: [".env"], + heartbeat_interval: "5m", + timeout_at: "2099-01-01T00:00:00Z", + checkpoint: "not-started", + resume_from: "task_card", + record_uri: ".trellis/tasks/01-role-test/hermes/worker_records.jsonl", + evidence_refs: [], + risk_flags: [], + }; +} + +function writeJsonl(file: string, records: unknown[]): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, records.map((record) => JSON.stringify(record)).join("\n") + "\n"); +} + +describe("Hermes canonical roles and profiles", () => { + let root: string; + let taskDir: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "research-trellis-roles-")); + fs.mkdirSync(path.join(root, ".trellis", "tasks", "01-role-test", "hermes"), { + recursive: true, + }); + fs.cpSync(TEMPLATE_SCRIPTS, path.join(root, ".trellis", "scripts"), { + recursive: true, + }); + taskDir = path.join(root, ".trellis", "tasks", "01-role-test"); + fs.writeFileSync( + path.join(taskDir, "task.json"), + JSON.stringify({ id: "01-role-test", status: "in_progress" }) + "\n", + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function run(script: string, args: string[]) { + return spawnSync( + "python3", + [path.join(root, ".trellis", "scripts", "hermes", script), ...args], + { cwd: root, encoding: "utf-8" }, + ); + } + + function append(card: ReturnType) { + return run("record.py", [ + "append", + "--task", + "01-role-test", + "--record-type", + "worker", + "--json", + JSON.stringify(card), + ]); + } + + it("writes only canonical roles and supplies each safe default profile", () => { + const defaults = { + planner: "task_planning", + researcher: "codebase", + coder: "implementation", + runner: "validation", + reviewer: "quality", + }; + for (const [index, [role, profile]] of Object.entries(defaults).entries()) { + const result = append(taskCard(role, { jobId: `job-${index}-${role}` })); + expect(result.status, result.stderr).toBe(0); + const records = fs + .readFileSync(path.join(taskDir, "hermes", "worker_records.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.at(-1)).toMatchObject({ role, profile }); + } + }); + + it("accepts every declared role/profile pair and rejects invalid combinations", () => { + const profiles: Record = { + planner: ["research_design", "task_planning", "root_cause", "method_selection"], + researcher: ["literature", "codebase", "external_docs", "prior_art"], + coder: ["implementation", "tests", "configuration", "repair"], + runner: ["experiment", "test", "build", "validation"], + reviewer: ["quality", "evidence", "claim", "safety", "closure", "statistics"], + }; + let index = 0; + for (const [role, roleProfiles] of Object.entries(profiles)) { + for (const profile of roleProfiles) { + const result = append(taskCard(role, { profile, jobId: `job-${index++}` })); + expect(result.status, `${role}:${profile} ${result.stderr}`).toBe(0); + } + } + const invalid = append(taskCard("coder", { profile: "statistics", jobId: "bad" })); + expect(invalid.status).toBe(2); + expect(invalid.stderr).toContain("invalid profile 'statistics' for role 'coder'"); + }); + + it("normalizes legacy role names without rewriting historical files", () => { + const cases = [ + ["scientist", "planner", "research_design", ""], + ["builder", "coder", "implementation", ""], + ["literature", "researcher", "literature", ""], + ["research/scout", "researcher", "external_docs", "查官方 API 文档"], + ["evaluator", "reviewer", "evidence", ""], + ["claim-reviewer", "reviewer", "claim", ""], + ["analyst", "reviewer", "statistics", "检查方差和置信区间"], + ["analyst", "reviewer", "statistics", ""], + ]; + for (const [index, [legacy, role, profile, objective]] of cases.entries()) { + const result = append( + taskCard(legacy, { + objective, + ...(index === cases.length - 1 ? { profile: "statistics" } : {}), + jobId: `legacy-${index}`, + }), + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("deprecated Hermes role"); + const latestLine = fs + .readFileSync(path.join(taskDir, "hermes", "worker_records.jsonl"), "utf-8") + .trim() + .split("\n") + .at(-1); + expect(latestLine).toBeDefined(); + const latest = JSON.parse(latestLine ?? "{}"); + expect(latest).toMatchObject({ role, profile }); + } + + const legacyPath = path.join(taskDir, "hermes", "worker_records.jsonl"); + writeJsonl(legacyPath, [taskCard("analyst", { jobId: "historical" })]); + const validate = run("validate.py", ["--task", "01-role-test", "--kind", "worker"]); + expect(validate.status, validate.stderr).toBe(0); + expect(validate.stderr).toContain("safe default planner:root_cause"); + expect(JSON.parse(fs.readFileSync(legacyPath, "utf-8")).role).toBe("analyst"); + }); + + it("rejects evidence-curator for new dispatch but reads historical records", () => { + const rejected = append(taskCard("evidence-curator")); + expect(rejected.status).toBe(2); + expect(rejected.stderr).toContain("tool-only"); + + writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [taskCard("evidence-curator")]); + const validate = run("validate.py", ["--task", "01-role-test", "--kind", "worker"]); + expect(validate.status, validate.stderr).toBe(0); + expect(validate.stderr).toContain("deterministic evidence command"); + }); + + it("does not let evidence review replace independent code-quality review", () => { + const checkpoint = (jobId: string) => ({ + type: "checkpoint", + id: `cp-${jobId}`, + timestamp: "2026-07-16T00:01:00Z", + job_id: jobId, + checkpoint: "checked", + resume_from: "complete", + evidence_refs: [], + open_items: [], + }); + const result = (jobId: string, status = "done", handoff = "done") => ({ + type: "result", + id: `result-${jobId}`, + timestamp: "2026-07-16T00:02:00Z", + job_id: jobId, + status, + summary: "complete", + changed_files: [], + evidence_refs: [], + risk_flags: [], + handoff, + }); + const coder = taskCard("coder", { jobId: "coder" }); + const runner = { ...taskCard("runner", { profile: "test", jobId: "runner" }), parent_job_id: "coder" }; + const reviewer = { + ...taskCard("reviewer", { profile: "evidence", jobId: "reviewer" }), + parent_job_id: "coder", + }; + const records = [ + coder, + runner, + checkpoint("runner"), + result("runner"), + reviewer, + checkpoint("reviewer"), + result("reviewer"), + checkpoint("coder"), + result("coder", "review", "review"), + ]; + const workerPath = path.join(taskDir, "hermes", "worker_records.jsonl"); + writeJsonl(workerPath, records); + const rejected = run("validate.py", ["--task", "01-role-test", "--kind", "worker"]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("requires reviewer result or checkpoint"); + + reviewer.profile = "quality"; + writeJsonl(workerPath, records); + const accepted = run("validate.py", ["--task", "01-role-test", "--kind", "worker"]); + expect(accepted.status, accepted.stderr).toBe(0); + }); + + it("collects and validates evidence deterministically", () => { + const content = "accuracy=0.91\n"; + fs.mkdirSync(path.join(root, "artifacts"), { recursive: true }); + fs.writeFileSync(path.join(root, "artifacts", "metrics.txt"), content); + const hash = `sha256:${createHash("sha256").update(content).digest("hex")}`; + writeJsonl(path.join(taskDir, "hermes", "artifact_ledger.jsonl"), [ + { + type: "artifact", + id: "artifact-1", + path: "artifacts/metrics.txt", + hash, + run_id: "run-1", + command_ref: "command-1", + summary: "metrics output", + }, + ]); + writeJsonl(path.join(taskDir, "hermes", "evidence_ledger.jsonl"), [ + { + type: "evidence", + id: "evidence-1", + timestamp: "2026-07-16T00:00:00Z", + source: "artifacts/metrics.txt", + summary: "accuracy is 0.91", + limits: "single fixture", + artifact_refs: ["artifact-1"], + command_refs: ["command-1"], + }, + ]); + + const validate = run("evidence.py", ["validate", "--task", "01-role-test"]); + expect(validate.status, validate.stderr).toBe(0); + const collect = run("evidence.py", ["collect", "--task", "01-role-test"]); + expect(collect.status, collect.stderr).toBe(0); + const summary = JSON.parse( + fs.readFileSync(path.join(taskDir, "hermes", "evidence_summary.json"), "utf-8"), + ); + expect(summary.counts).toEqual({ artifacts: 1, evidence: 1, run_manifests: 0 }); + expect(summary.gaps.dangling_artifact_refs).toEqual([]); + expect(summary.validation_errors).toEqual([]); + }); +}); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index bbf35c7..50002eb 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -356,6 +356,26 @@ function runHermesRuntimeGuard( } } +function runDispatchCli( + repoRoot: string, + args: string[], +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync( + PYTHON, + [path.join(repoRoot, ".trellis", "scripts", "hermes", "dispatch.py"), ...args], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + describe("shared-hooks capability table", () => { it("every capability-table entry names a real shared-hook file", () => { const realFiles = new Set(getSharedHookScripts().map((h) => h.name)); @@ -513,6 +533,72 @@ describe("shared-hooks capability table", () => { expect(content).not.toContain(""); }); + it("lists project context references for the main agent without inlining documents", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-project-index-")); + try { + setupMainRepo(repoRoot, "project-index", "# prd\n"); + const projectDir = path.join(repoRoot, ".trellis", "project"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "BACKGROUND.md"), + "BACKGROUND_BODY_MUST_NOT_BE_INJECTED\n", + ); + fs.writeFileSync(path.join(projectDir, "RESEARCH_PLAN.md"), "# Plan\n"); + fs.writeFileSync(path.join(projectDir, "CONSTRAINTS.md"), "# Constraints\n"); + + const payload = JSON.parse(runSessionStart(repoRoot)) as { + hookSpecificOutput?: { additionalContext?: string }; + }; + const context = payload.hookSpecificOutput?.additionalContext ?? ""; + expect(context).toContain(""); + expect(context).toContain(".trellis/project/BACKGROUND.md"); + expect(context).toContain(".trellis/project/RESEARCH_PLAN.md"); + expect(context).toContain(".trellis/project/CONSTRAINTS.md"); + expect(context).toContain("Subagents receive a project-context document only"); + expect(context).not.toContain("BACKGROUND_BODY_MUST_NOT_BE_INJECTED"); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("gives Codex the same project context index through its first prompt hook", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-project-index-")); + try { + setupMainRepo(repoRoot, "project-index", "# prd\n"); + const projectDir = path.join(repoRoot, ".trellis", "project"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "BACKGROUND.md"), + "CODEX_BACKGROUND_BODY_MUST_NOT_BE_INJECTED\n", + ); + fs.writeFileSync(path.join(projectDir, "RESEARCH_PLAN.md"), "# Plan\n"); + fs.writeFileSync(path.join(projectDir, "CONSTRAINTS.md"), "# Constraints\n"); + + const hook = getSharedHookScripts().find( + (item) => item.name === "inject-workflow-state.py", + ); + if (!hook) throw new Error("inject-workflow-state.py template missing"); + const hookPath = path.join(repoRoot, ".codex", "hooks", "inject-workflow-state.py"); + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, hook.content); + const result = spawnSync(PYTHON, [hookPath], { + cwd: repoRoot, + encoding: "utf-8", + input: JSON.stringify({ cwd: repoRoot }), + }); + expect(result.status, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as { + hookSpecificOutput?: { additionalContext?: string }; + }; + const context = payload.hookSpecificOutput?.additionalContext ?? ""; + expect(context).toContain(""); + expect(context).toContain(".trellis/project/BACKGROUND.md"); + expect(context).not.toContain("CODEX_BACKGROUND_BODY_MUST_NOT_BE_INJECTED"); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("injects a compact closure capsule without full task artifacts", () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-capsule-hook-")); try { @@ -565,12 +651,38 @@ describe("shared-hooks capability table", () => { expect(context).toContain(""); expect(context).toContain("Current: WP1"); expect(context).toContain("Done when: Focused test passes"); - expect(context).toContain("use only the Task Capsule"); + expect(context).toContain("Hermes closure task: capsule-task"); + expect(context).toContain("Route: delivery"); + expect(context).toContain("Route rule:"); expect(context).not.toContain("Claude review gates are read-only gates"); expect(context).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); const capsule = /\n([\s\S]*?)\n<\/task-capsule>/.exec(context)?.[1] ?? ""; expect(capsule.length).toBeLessThanOrEqual(1000); + + const repeated = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "anchor revision", + ); + expect(repeated.hookSpecificOutput.additionalContext).not.toContain( + "", + ); + + const revisedTask = JSON.parse(fs.readFileSync(taskJsonPath, "utf-8")); + revisedTask.hermes_revision = 1; + revisedTask.next_action = "Record focused validation evidence"; + fs.writeFileSync(taskJsonPath, `${JSON.stringify(revisedTask, null, 2)}\n`); + const revised = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(revised.hookSpecificOutput.additionalContext).toContain( + "revision 0 -> 1", + ); + expect(revised.hookSpecificOutput.additionalContext).toContain( + "", + ); } finally { fs.rmSync(repoRoot, { recursive: true, force: true }); } @@ -587,7 +699,7 @@ describe("shared-hooks capability table", () => { expect(content).toContain( ".trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md", ); - expect(content).toContain("minimal_file_context"); + expect(content).toContain("validated_dispatch_only"); expect(content).not.toContain("You are running inside a Hermes-governed"); }); @@ -944,6 +1056,117 @@ describe.skipIf(!hasPython())( ); }); + it("injects only the current Hermes role, profile, capsule, and direct refs", () => { + setupMainRepo(repoRoot, taskName, "# prd\nFULL_PRD_MUST_NOT_BE_INJECTED\n"); + const taskDir = path.join(repoRoot, ".trellis", "tasks", taskName); + fs.writeFileSync( + path.join(taskDir, "task.json"), + JSON.stringify({ + id: taskName, + title: "Compact review", + status: "in_progress", + hermes_revision: 0, + closure_state: "open", + closure_mode: "lean", + hermes_phase: "review", + intent: "Review evidence for the current package", + in_scope: ["evidence"], + out_of_scope: ["implementation"], + definition_of_done: ["Evidence review recorded"], + work_packages: [ + { + id: "WP1", + outcome: "Evidence is independently reviewed", + done_when: ["Review record exists"], + status: "review", + }, + ], + current_work_package: "WP1", + next_action: "Review WP1 evidence", + blockers: [], + relatedFiles: ["docs/direct.md", "docs/second.md", "docs/third.md", "docs/fourth.md"], + }) + "\n", + ); + fs.mkdirSync(path.join(repoRoot, "docs"), { recursive: true }); + for (const name of ["direct.md", "second.md", "third.md", "fourth.md"]) { + fs.writeFileSync(path.join(repoRoot, "docs", name), name, "utf-8"); + } + const created = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "review-evidence", + "--role", "reviewer", "--profile", "evidence", + "--objective", "Review the current package evidence.", + "--ref", "docs/direct.md", "--ref", "docs/second.md", "--ref", "docs/third.md", + ]); + expect(created.status, created.stderr).toBe(0); + const contextId = setSessionActiveTask(repoRoot, taskName, "hermes-role-session"); + const result = runPreToolUseHook( + repoRoot, + { + cwd: repoRoot, + tool_name: "Agent", + tool_input: { + subagent_type: "hermes-reviewer", + prompt: "job_id: review-evidence", + }, + }, + { + CLAUDE_PROJECT_DIR: repoRoot, + TRELLIS_CONTEXT_ID: contextId, + }, + ); + const prompt = String(result.hookSpecificOutput?.updatedInput?.prompt ?? ""); + expect(prompt).toContain("Hermes Agent Context Firewall dispatch"); + expect(prompt).toContain("role: reviewer:evidence"); + expect(prompt).toContain("work_package: null"); + expect(prompt).toContain("docs/direct.md"); + expect(prompt).not.toContain("docs/fourth.md"); + expect(prompt).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); + expect(prompt).not.toContain("# design"); + expect(prompt.length).toBeLessThanOrEqual(2000); + }); + + it("keeps installed legacy Hermes agent aliases on the compact canonical path", () => { + setupMainRepo(repoRoot, taskName, "# prd\nLEGACY_FULL_PRD\n"); + const taskPath = path.join(repoRoot, ".trellis", "tasks", taskName, "task.json"); + const task = JSON.parse(fs.readFileSync(taskPath, "utf-8")); + Object.assign(task, { + hermes_revision: 0, + closure_state: "open", + closure_mode: "lean", + hermes_phase: "review", + intent: "Review evidence", + definition_of_done: ["Evidence reviewed"], + work_packages: [], + current_work_package: null, + }); + fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + const created = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "legacy-evidence", + "--role", "reviewer", "--profile", "evidence", + "--objective", "Evaluate evidence.", + ]); + expect(created.status, created.stderr).toBe(0); + const contextId = setSessionActiveTask(repoRoot, taskName, "legacy-role-session"); + const result = runPreToolUseHook( + repoRoot, + { + cwd: repoRoot, + tool_name: "Agent", + tool_input: { + subagent_type: "hermes-evaluator", + prompt: "legacy-evidence", + }, + }, + { + CLAUDE_PROJECT_DIR: repoRoot, + TRELLIS_CONTEXT_ID: contextId, + }, + ); + const prompt = String(result.hookSpecificOutput?.updatedInput?.prompt ?? ""); + expect(prompt).toContain("role: reviewer:evidence"); + expect(prompt).not.toContain("LEGACY_FULL_PRD"); + }); + it("covers the documented A/B/C strategy block format in implement.md", () => { setupMainRepo( repoRoot, @@ -1224,6 +1447,25 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "research-trellis-hook-")); repoRoot = path.join(tmpDir, "repo"); setupMainRepo(repoRoot, taskName, "# prd\n"); + const taskPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "task.json", + ); + const task = JSON.parse(fs.readFileSync(taskPath, "utf-8")); + Object.assign(task, { + hermes_revision: 0, + closure_state: "open", + closure_mode: "lean", + hermes_phase: "running", + intent: "Runtime guard fixture", + definition_of_done: ["Runtime checks pass"], + work_packages: [], + current_work_package: null, + }); + fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); setSessionActiveTask(repoRoot, taskName); }); @@ -1677,6 +1919,27 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); + it("allows parameterized Hermes control-plane commands for the main agent", () => { + for (const command of [ + "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo --role coder --objective bounded", + "python3 ./.trellis/scripts/closure.py audit --task demo", + "python3 ./.trellis/scripts/task.py archive demo --no-commit", + ]) { + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, + session_id: "test-session", + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + } + expectMainAgentBashDenied( + "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo; touch src/app.ts", + ); + }); + it("allows main-agent git status short paths as read-only Bash", () => { const result = runHermesRuntimeGuard(repoRoot, { cwd: repoRoot, @@ -1849,6 +2112,9 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { { subagent: "runner" }, { agent_role: "hermes-coder" }, { agent_role: "hermes-scientist" }, + { agent_role: "scientist" }, + { agent_role: "claim-reviewer" }, + { agent_role: "research/scout" }, { subagent_type: "trellis-implement" }, { subagent_type: "trellis-spec-review" }, { subagent_type: "trellis-code-architecture-review" }, diff --git a/packages/cli/test/templates/trellis.test.ts b/packages/cli/test/templates/trellis.test.ts index aab0bce..5f47d7f 100644 --- a/packages/cli/test/templates/trellis.test.ts +++ b/packages/cli/test/templates/trellis.test.ts @@ -309,14 +309,14 @@ describe("trellis template constants", () => { ); }); - it("workflow.md planning breadcrumb keeps requirement clarification before strategy decisions on Claude Code path", () => { + it("workflow.md analyzes every task but limits grill to unresolved decisions", () => { const planning = workflowStateBreadcrumb("planning"); expect(planning).toContain("trellis-grill-me"); expect(planning).toContain("development strategy decision"); - expect(planning).toContain( - "`trellis-grill-me` is a required planning gate", - ); - expect(planning).toContain("Before `trellis-grill-me` is complete"); + expect(planning).toContain("Before every plan or dispatch, the main agent must analyze"); + expect(planning).toContain("Do not load `trellis-brainstorm` or `trellis-grill-me` by default"); + expect(planning).toContain("only for an unresolved material decision"); + expect(planning).toContain("When a grill is running"); expect(planning).toContain("do not enter development strategy decisions"); expect(planning).toContain( "do not create or complete `design.md` / `implement.md`", @@ -327,6 +327,29 @@ describe("trellis template constants", () => { ); }); + it("workflow.md separates protocol-changing research from ordinary work packages", () => { + expect(workflowMdTemplate).toContain("科研任务按研究协议是否改变分流"); + expect(workflowMdTemplate).toContain("模型功能架构变化属于高风险探索"); + expect(workflowMdTemplate).toContain("必须创建独立探索任务"); + }); + + it("shared planning skills keep routine Lean Closure work free of a grill gate", () => { + const repoRoot = fs.existsSync(path.join(process.cwd(), "marketplace")) + ? process.cwd() + : path.resolve(process.cwd(), "../.."); + const brainstorm = fs.readFileSync( + path.join(repoRoot, "packages/cli/src/templates/common/skills/brainstorm.md"), + "utf-8", + ); + const start = fs.readFileSync( + path.join(repoRoot, "packages/cli/src/templates/common/commands/start.md"), + "utf-8", + ); + expect(brainstorm).toContain("Do not enter `trellis-grill-me` only because a task is legacy or complex"); + expect(start).toContain("For a Lean Research Closure task, read the Task Capsule first"); + expect(start).toContain("legacy or explicitly complex Claude Code tasks"); + }); + it("workflow.md planning breadcrumb records Claude Code development strategy decisions before start", () => { const planning = workflowStateBreadcrumb("planning"); expect(planning).toContain("development mode"); @@ -390,14 +413,11 @@ describe("trellis template constants", () => { const ledgersReadme = readTemplateFile("trellis/hermes/ledgers/README.md"); const metricsSchema = readTemplateFile("trellis/hermes/metrics/metrics_schema.yaml"); const roleFiles = [ - "scientist.md", + "planner.md", + "researcher.md", "coder.md", "runner.md", - "evaluator.md", "reviewer.md", - "literature.md", - "evidence-curator.md", - "claim-reviewer.md", ]; expect(config).toContain("hermes_version"); @@ -448,10 +468,13 @@ describe("trellis template constants", () => { for (const roleFile of roleFiles) { const role = readTemplateFile(`trellis/hermes/roles/${roleFile}`); - expect(role).toContain("Responsibilities"); - expect(role).toContain("Must not"); - expect(role).toContain("worker_records.jsonl"); - expect(role).toContain("HumanGate"); + expect(role).toContain("Role purpose"); + expect(role).toContain("Allowed actions"); + expect(role).toContain("Forbidden actions"); + expect(role).toContain("Required output"); + expect(role).toContain("Available profiles"); + expect(role).toContain("Default profile"); + expect(role).toContain("Completion conditions"); } const ledgerFiles = fs.readdirSync(templateDir("trellis/hermes/ledgers")); @@ -521,12 +544,9 @@ describe("trellis template constants", () => { expect(reportTemplate).toContain("evidence:"); expect(reportTemplate).toContain("claim_ready"); - const evaluator = readTemplateFile("trellis/hermes/roles/evaluator.md"); - expect(evaluator).toContain("Must not modify source files"); - expect(evaluator).toContain("Must not modify metrics, split, or baseline"); const reviewer = readTemplateFile("trellis/hermes/roles/reviewer.md"); - expect(reviewer).toContain("only read the current diff, records, evidence"); - expect(reviewer).toContain("Must not inherit coder long conversation"); + expect(reviewer).toContain("Read the current diff"); + expect(reviewer).toContain("Use inherited coder or runner conversation as evidence"); expect(subagentProtocol).toContain("main agent"); expect(subagentProtocol).toContain("supervisor"); @@ -539,7 +559,7 @@ describe("trellis template constants", () => { expect(subagentProtocol).toContain("one active writer"); expect(mainAgentBootGuard).toContain("Main Agent / Main Pilot"); - expect(mainAgentBootGuard).toContain("minimal_file_context"); + expect(mainAgentBootGuard).toContain("validated_dispatch_only"); expect(mainAgentBootGuard).toContain("No record means no completion"); expect(mainAgentBootGuard).toContain("claim_allowed=true"); expect(mainAgentBootGuard).toContain("human / PI"); @@ -550,14 +570,12 @@ describe("trellis template constants", () => { const templateKeys = [ "config.yaml", "state_machine.yaml", - "roles/scientist.md", + "roles/base.md", + "roles/planner.md", + "roles/researcher.md", "roles/coder.md", "roles/runner.md", - "roles/evaluator.md", "roles/reviewer.md", - "roles/literature.md", - "roles/evidence-curator.md", - "roles/claim-reviewer.md", "records/README.md", "records/recordbus.md", "records/subagent_protocol.md", @@ -568,6 +586,7 @@ describe("trellis template constants", () => { "metrics/metrics_schema.yaml", "experiments/README.md", "experiments/experiment.yaml", + "schemas/result-envelope.schema.json", ]; for (const key of templateKeys) { @@ -582,6 +601,9 @@ describe("trellis template constants", () => { ); expect(templates.get("metrics/metrics_schema.yaml")).toContain("HumanGate"); expect(templates.get("reports/report.md")).toContain("Core Conclusions"); + expect(templates.get("schemas/result-envelope.schema.json")).toContain( + "uncertainties", + ); }); }); @@ -761,11 +783,16 @@ describe("getAllScripts", () => { expect(scripts.has("common/__init__.py")).toBe(true); expect(scripts.has("common/paths.py")).toBe(true); expect(scripts.has("common/active_task.py")).toBe(true); + expect(scripts.has("common/roles.py")).toBe(true); + expect(scripts.has("common/dispatch.py")).toBe(true); + expect(scripts.has("common/firewall.py")).toBe(true); expect(scripts.has("common/worktree_sync.py")).toBe(true); expect(scripts.has("hermes/__init__.py")).toBe(true); expect(scripts.has("hermes/runtime.py")).toBe(true); expect(scripts.has("hermes/record.py")).toBe(true); expect(scripts.has("hermes/validate.py")).toBe(true); + expect(scripts.has("hermes/evidence.py")).toBe(true); + expect(scripts.has("hermes/dispatch.py")).toBe(true); expect(scripts.has("hermes/guard.py")).toBe(true); expect(scripts.has("hermes/jobs.py")).toBe(true); expect(scripts.has("hermes/heartbeat.py")).toBe(true); diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..0c97efd --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..22bee76 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,290 @@ +# Research Trellis + +面向科研项目的 `Trellis`(原版 Trellis)实验性改造分支。 + +[![CI](https://github.com/Explero/research-trellis/actions/workflows/ci.yml/badge.svg)](https://github.com/Explero/research-trellis/actions/workflows/ci.yml) +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/license-AGPL--3.0--or--later-16a34a.svg?style=flat-square)](LICENSE) + +这个仓库是在原版 `Trellis`(原版项目)基础上改造出来的科研实验分支,不是上游官方版本,也不代表上游项目立场。它更接近一个科研项目里的工作流工具实验:在原版 Trellis 的任务、规范、平台接入基础上,加入更强的 `Hermes`(科研工作流)记录、门禁和子代理协作约束。 + +当前状态:可以放进低风险真实科研项目里试部署;不建议直接用于不可回滚的关键项目。 + +从 `0.6.0-beta.31`(测试版)开始,项目使用 `research-trellis`(CLI 包)和 `research-trellis-core`(核心包),与上游原版名称明确区分。旧包 `trellis-hermes` 和 `trellis-hermes-core` 停留在 `0.6.0-beta.30`(旧测试版),仅用于已有安装迁移。 + +## 上游来源与修改声明 + +本项目基于 `mindfold-ai/Trellis`(上游 Trellis 项目)继续修改,原项目地址为 `https://github.com/mindfold-ai/Trellis`(上游仓库)。 + +为了避免混淆,这里先说明几个边界: + +- `Research Trellis`(科研改造版)不是上游 `Trellis`(原版项目)的官方发布版本; +- 仓库中的原始 Trellis 代码、模板、文档结构和许可证声明应继续归属于其原始作者和贡献者; +- 本仓库新增和改动的部分主要围绕 `Hermes`(科研工作流)、记录门禁、子代理治理、科研计划变更记录和发布前预检; +- 如果后续公开发布到 `npm`(包平台)或 `GitHub`(代码托管平台),应继续保留上游来源、许可证和修改说明。 + +## 这是什么 + +`Research Trellis`(科研改造版)是一个本地开发工具,用来把 AI 协作开发过程里的关键动作落到仓库文件中: + +- 需求、设计、实现计划和检查记录放进 `.trellis/tasks/`; +- 项目规范和工作流规则放进 `.trellis/spec/` 与 `.trellis/workflow.md`; +- 子代理执行记录、实验配置、指标结构和报告模板放进 `.trellis/hermes/` 相关目录; +- 关键门禁采用“缺记录就失败”的方式,减少 AI 在流程里跳步。 + +它的目标不是让 AI 更快地直接写代码,而是让科研项目里的 AI 协作更可追踪、可复盘、可审查。 + +## 当前验证状态 + +远程 `CI`(持续集成)已经配置在 GitHub Actions 中。每次推送到 `main`(主分支)或提交 `pull request`(合并请求)时,会运行: + +```bash +pnpm install --frozen-lockfile +pnpm lint +pnpm test +pnpm build +pnpm --filter research-trellis hermes:preflight +``` + +这些检查覆盖: + +- TypeScript 代码规范; +- 核心包与 CLI 的自动化测试; +- 构建产物生成; +- Hermes 模板、Python 文件、门禁配置和预检逻辑。 + +你可以在 GitHub 的 `Actions`(自动化运行)页面里查看最新结果。绿色通过只说明当前仓库在干净环境里能安装、测试、构建和通过 Hermes 预检;它不等于已经完成公开发布级验证。 + +## CD 发布准备 + +仓库已经准备了 `.github/workflows/publish.yml`(发布流程)。这个流程不会在普通 `push`(推送)时发布,只会在 GitHub 上发布 `Release`(发布版本)时触发。 + +发布流程会重新运行: + +```bash +pnpm lint +pnpm test +pnpm build +pnpm --filter research-trellis hermes:preflight +node packages/cli/scripts/release-preflight.js check-versions --require-tag +node packages/cli/scripts/release-preflight.js verify-packed-cli +``` + +检查通过后,它会先发布 `research-trellis-core`(核心包),再发布 `research-trellis`(CLI 包),最后从 npm registry 验证两个包是否可见。 + +发布前还需要在 `npm`(包平台)侧完成: + +- 创建或认领 `research-trellis-core` 与 `research-trellis` 两个包; +- 给两个包配置 `trusted publishing`(可信发布); +- `Owner`(所有者)填写 `Explero`,`Repository`(仓库)填写 `research-trellis`; +- `Workflow filename`(流程文件名)填写 `publish.yml`; +- `Environment`(环境)填写 `npm-production`。 + +这个仓库不会保存 `NPM_TOKEN`(npm 令牌)。发布依赖 GitHub Actions 的 `id-token: write`(OIDC 身份令牌权限)和 npm 的 `trusted publishing`(可信发布)配置。 + +## 适合先放到什么项目里试 + +建议先选择一个低风险真实项目: + +- 有真实科研流程,不是空目录; +- 即使工具生成内容不理想,也能通过 git 回滚; +- 不包含必须保密的凭据、数据或不可提交文件; +- 当前任务规模适中,适合观察完整流程。 + +不建议一开始就放进最重要的主项目,尤其不要在没有备份的情况下直接覆盖现有 `.trellis/`、`.claude/`、`.codex/` 或 `AGENTS.md`。 + +## 从源码试用 + +新包发布后,可固定安装本次测试版: + +```bash +npm install -g research-trellis@0.7.1-beta.0 +research-trellis --version +``` + +原有 `trellis` 与 `tl` 命令继续可用,便于已有脚本迁移;新文档统一使用 `research-trellis` 作为主命令。 + +已经安装旧包时,发布后按下面的方式迁移: + +```bash +npm uninstall -g trellis-hermes +npm install -g research-trellis@0.7.1-beta.0 +research-trellis --version +``` + +需要修改或审阅实现时,再从 GitHub 源码运行: + +```bash +git clone git@github.com:Explero/research-trellis.git +cd research-trellis +corepack enable +pnpm install --frozen-lockfile +pnpm build +``` + +然后进入要试部署的科研项目: + +```bash +cd /path/to/your/research-project +git status +git add . +git commit -m "chore: checkpoint before Research Trellis trial" +node /path/to/research-trellis/packages/cli/bin/trellis.js init -u your-name --claude +``` + +如果项目里已经有 Trellis,可以先用 `update`(更新)而不是重新初始化: + +```bash +node /path/to/research-trellis/packages/cli/bin/trellis.js update +``` + +执行前请先看清楚提示,涉及覆盖文件时不要盲目确认。 + +## 建议的真实项目试跑方式 + +第一次试跑不要追求复杂任务,先验证一条短链路: + +```text +准备一个干净 git checkpoint +→ 初始化或更新 Research Trellis +→ 用自然语言提出一个小任务 +→ 让 AI 创建任务并补齐 planning +→ 用 closure plan/validate 生成 1–4 个结果型工作包 +→ 检查 .trellis/tasks/ 下的 prd/design/implement 产物 +→ 派发一个低风险实现或检查子任务 +→ 查看 Hermes worker records、报告和门禁结果 +→ 运行项目自己的测试 +→ 执行 closure audit/close,确认不能提前结束 +→ 决定保留、回滚或继续调整 +``` + +这一步主要验证三件事: + +1. 它能不能适配你的项目结构; +2. 它生成的任务文档是否方便你审阅; +3. Hermes 门禁是否真的能阻止“没有记录就继续”的情况。 + +## Hermes 改造重点 + +### 1. 子代理记录必须可追踪 + +科研项目里最麻烦的不是 AI 没做事,而是做了什么说不清。这个分支要求关键子代理动作写入记录文件,后续检查会读取这些记录。 + +如果记录缺失、为空或格式不对,相关门禁会失败,而不是默认放行。 + +### 2. 三道闸门形成闭环 + +`PreToolUse`(工具使用前)负责拦写入权限和危险动作;`RecordBus`(记录总线)负责保存每个 `agent`(代理)的 `JSONL`(逐行 JSON)记录;`Stop`(结束前)只读取 `RecordBus`(记录总线)、`git diff`(Git 差异)和测试运行记录做最终判定。 + +也就是说:没有完成的 `coder`(编码代理)记录、没有 `passing`(通过)的 `runner`(运行代理)测试记录、没有 `reviewer`(审核代理)记录,任务就不能被判定为完成。 + +### 3. 任务计划先落盘 + +任务不再只是聊天里的临时约定。`prd.md`、`design.md`、`implement.md`、`implement.jsonl` 和 `check.jsonl` 用来保存需求、设计、实现策略和子代理上下文。 + +这样做的代价是流程会慢一点;收益是后面可以 review、复盘和修正。 + +### 4. 共享 worktree 策略更严格 + +对于需要子代理实现的任务,科研版更强调使用明确的共享工作目录,避免多个代理在不同目录里重复实现同一个任务。 + +如果配置指向普通目录、无关仓库或不可识别的 worktree,流程会拒绝继续。 + +### 5. Lean Research Closure 防止提前结束 + +新任务默认使用一个 Trellis task,并在内部安排 1–4 个结果型工作包。`task.json` 保存当前状态,`hermes/task-events.jsonl` 追加重要变化;每轮 hook 优先注入紧凑 Task Capsule,减少完整 PRD、历史报告和全部规范的重复加载。 + +只有 `closure.py audit`(收口审计)通过后,`closure.py close`(关闭任务)才会写入 `completed`(已完成)。`lean`(轻量)、`standard`(标准)和 `publication`(发表)按需增加证据门禁;有限 repair(修复)不会扩大任务范围或改写已完成工作包。 + +详见[中文手册:Lean Research Closure](docs/manual/17-lean-research-closure.md)。 + +### 6. 五角色与轻量模式 + +五个正式 `Hermes`(科研工作流)角色现在是 `planner`(规划代理)、`researcher`(检索代理)、`coder`(编码代理)、`runner`(运行代理)和 `reviewer`(复核代理)。角色决定权限,`Profile`(模式)决定本次关注点;正式模板目前只提供给 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。 + +### 7. Agent Context Firewall + +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 + +返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 + +### 8. 预检不是形式检查 + +`hermes:preflight`(Hermes 预检)会检查模板文件、Python 编译、Hermes hook、门禁文档、沙箱配置、模板测试、类型检查和构建。 + +它不能证明项目已经适合生产环境,但可以挡住一批明显会在使用阶段爆出来的问题。 + +## 与原版 Trellis 的关系 + +这个项目基于原版 `Trellis`(原版 AI coding 工作流工具)继续改造,仍然保留: + +- `research-trellis init` / `research-trellis update`; +- `.trellis/spec/` 项目规范; +- `.trellis/tasks/` 任务系统; +- 多平台接入模板; +- CLI 构建与测试体系。 + +科研版新增或强化的部分主要在: + +- Hermes 实验记录; +- 子代理记录门禁; +- planning 产物约束; +- Lean Research Closure、Task Capsule 和有限修复; +- 共享 worktree 检查; +- 发布前预检。 + +后续若再次改名或发布新版本,需同步核对包名、仓库链接、许可证说明、上游致谢、发布脚本和安装文档。 + +## 目录速览 + +```text +. +├── .github/workflows/ci.yml # GitHub Actions 远程检测 +├── packages/core/ # 核心领域逻辑 +├── packages/cli/ # trellis CLI 和模板 +├── packages/cli/src/templates/ # 初始化到用户项目里的模板 +├── packages/cli/src/templates/trellis/hermes/ +│ ├── config.yaml # Hermes 配置 +│ ├── state_machine.yaml # Hermes 状态机 +│ ├── records/ # 子代理记录协议 +│ ├── roles/ # 科研角色提示词 +│ ├── metrics/ # 指标结构 +│ └── reports/ # 报告模板 +└── package.json # workspace 脚本 +``` + +真实项目初始化后,重点看目标项目里的: + +```text +.trellis/ +├── spec/ +├── tasks/ +├── workflow.md +└── hermes/ +``` + +## 本地开发检查 + +修改这个仓库后,至少运行: + +```bash +pnpm lint +pnpm test +pnpm build +pnpm --filter research-trellis hermes:preflight +``` + +如果改动涉及 Hermes 模板、hook 或任务流,优先补充对应测试,再看 GitHub Actions 结果。 + +## 重要边界 + +- 这不是操作系统级沙箱,不能防止恶意命令执行。 +- Hermes 的 JSONL 记录不是防篡改存储。 +- `allowed_commands`(允许命令)只能作为流程门禁的一部分,不应理解成强安全边界。 +- 不要把 API Key、密码、令牌、私有数据或 `.env` 文件提交进仓库。 +- 真实科研项目试部署前,先做 git checkpoint。 + +## License + +本项目沿用上游项目授予的 `AGPL-3.0-or-later`(AGPL 第 3 版或任何后续版本)许可,不缩减 `COPYRIGHT`(版权声明)原文授予的权利。 + +本仓库是基于 `mindfold-ai/Trellis`(上游 Trellis 项目)的修改版本。公开发布或分发时,应保留上游许可证、上游来源和本仓库的修改说明;当前仓库名称、包名和 `Hermes`(科研工作流)相关改造不表示上游官方认可或背书。 diff --git a/packages/core/package.json b/packages/core/package.json index e888c68..1d4fc69 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "research-trellis-core", - "version": "0.7.0-beta.0", + "version": "0.7.1-beta.0", "description": "Research Trellis core SDK for channels, tasks, memory, and auditable research records", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/src/task/index.ts b/packages/core/src/task/index.ts index c8d018f..f2d0706 100644 --- a/packages/core/src/task/index.ts +++ b/packages/core/src/task/index.ts @@ -4,6 +4,8 @@ // Task API is intentionally independent from the channel API. export type { + ClosureConstraints, + ResearchRoute, TrellisTaskRecord, TaskRecordField, } from "./schema.js"; diff --git a/packages/core/src/task/schema.ts b/packages/core/src/task/schema.ts index 0ffcf8b..3c31dd7 100644 --- a/packages/core/src/task/schema.ts +++ b/packages/core/src/task/schema.ts @@ -2,7 +2,7 @@ * Canonical task.json shape — single source of truth for Trellis tasks. * * The runtime Python writer is `.trellis/scripts/common/task_store.py` - * (`cmd_create`). The 24-field shape and field order below mirror that + * (`cmd_create`). The field shape and field order below mirror that * writer exactly so every TS and Python entry point produces structurally * identical task.json files. * @@ -11,6 +11,7 @@ * task.json shape. */ export type ClosureMode = "lean" | "standard" | "publication"; +export type ResearchRoute = "delivery" | "execution" | "exploration"; export type HermesPhase = | "planning" | "ready" @@ -38,6 +39,15 @@ export interface ClosureWorkPackage { status: ClosureWorkPackageStatus; evidence_refs: string[]; blocker: string | null; + confirmed_dispatches?: string[]; + dispatch_blockers?: string[]; + dispatch_blocked_from_status?: "running" | "review"; +} + +export interface ClosureConstraints { + excluded_platforms: string[]; + excluded_paths: string[]; + validation_level: "targeted" | "basic" | "standard"; } export interface TrellisTaskRecord { @@ -72,12 +82,19 @@ export interface TrellisTaskRecord { in_scope?: string[]; out_of_scope?: string[]; definition_of_done?: string[]; + context_pins?: string[]; + research_route?: ResearchRoute; + research_change_fields?: string[]; + grill_completed?: boolean; + constraints?: ClosureConstraints; work_packages?: ClosureWorkPackage[]; current_work_package?: string | null; next_action?: string | null; blockers?: string[]; repair_count?: number; max_repair_count?: number; + hermes_revision?: number; + confirmed_dispatches?: string[]; } /** @@ -116,12 +133,19 @@ export const TASK_RECORD_FIELD_ORDER = [ "in_scope", "out_of_scope", "definition_of_done", + "context_pins", + "research_route", + "research_change_fields", + "grill_completed", + "constraints", "work_packages", "current_work_package", "next_action", "blockers", "repair_count", "max_repair_count", + "hermes_revision", + "confirmed_dispatches", ] as const satisfies readonly (keyof TrellisTaskRecord)[]; export type TaskRecordField = (typeof TASK_RECORD_FIELD_ORDER)[number]; @@ -141,6 +165,7 @@ const STRING_FIELDS: ReadonlySet = new Set([ "closure_state", "closure_mode", "intent", + "research_route", ]); const NULLABLE_STRING_FIELDS: ReadonlySet = new Set([ @@ -165,7 +190,10 @@ const STRING_ARRAY_FIELDS: ReadonlySet = new Set([ "in_scope", "out_of_scope", "definition_of_done", + "context_pins", + "research_change_fields", "blockers", + "confirmed_dispatches", ]); const OPTIONAL_CLOSURE_FIELDS: ReadonlySet = new Set([ @@ -176,12 +204,19 @@ const OPTIONAL_CLOSURE_FIELDS: ReadonlySet = new Set([ "in_scope", "out_of_scope", "definition_of_done", + "context_pins", + "research_route", + "research_change_fields", + "grill_completed", + "constraints", "work_packages", "current_work_package", "next_action", "blockers", "repair_count", "max_repair_count", + "hermes_revision", + "confirmed_dispatches", ]); const CLOSURE_MODES: ReadonlySet = new Set([ @@ -198,6 +233,11 @@ const HERMES_PHASES: ReadonlySet = new Set([ "closed", ]); const CLOSURE_STATES: ReadonlySet = new Set(["open", "closed"]); +const RESEARCH_ROUTES: ReadonlySet = new Set([ + "delivery", + "execution", + "exploration", +]); /** * Lightweight runtime schema for {@link TrellisTaskRecord}. Zero-dep on @@ -268,6 +308,9 @@ function assignField( if (field === "closure_state" && !CLOSURE_STATES.has(value)) { throw new Error("task.closure_state is invalid"); } + if (field === "research_route" && !RESEARCH_ROUTES.has(value)) { + throw new Error("task.research_route is invalid"); + } bag[field] = value; return; } @@ -292,6 +335,36 @@ function assignField( record.meta = cloneJsonObject(value, "task.meta"); return; } + if (field === "grill_completed") { + if (typeof value !== "boolean") { + throw new Error("task.grill_completed must be a boolean"); + } + record.grill_completed = value; + return; + } + if (field === "constraints") { + if (!isPlainObject(value)) { + throw new Error("task.constraints must be a JSON object"); + } + const platforms = value.excluded_platforms; + const paths = value.excluded_paths; + const level = value.validation_level; + if ( + !Array.isArray(platforms) || + platforms.some((item) => typeof item !== "string") || + !Array.isArray(paths) || + paths.some((item) => typeof item !== "string") || + !["targeted", "basic", "standard"].includes(String(level)) + ) { + throw new Error("task.constraints is invalid"); + } + record.constraints = { + excluded_platforms: [...platforms] as string[], + excluded_paths: [...paths] as string[], + validation_level: level as ClosureConstraints["validation_level"], + }; + return; + } if (field === "work_packages") { if (!Array.isArray(value)) { throw new Error("task.work_packages must be an array"); @@ -301,7 +374,11 @@ function assignField( ); return; } - if (field === "repair_count" || field === "max_repair_count") { + if ( + field === "repair_count" || + field === "max_repair_count" || + field === "hermes_revision" + ) { if (!Number.isInteger(value) || (value as number) < 0) { throw new Error(`task.${field} must be a non-negative integer`); } @@ -357,12 +434,23 @@ export function emptyTaskRecord( in_scope: [], out_of_scope: [], definition_of_done: [], + context_pins: [], + research_route: "delivery", + research_change_fields: [], + grill_completed: false, + constraints: { + excluded_platforms: [], + excluded_paths: [], + validation_level: "targeted", + }, work_packages: [], current_work_package: null, next_action: null, blockers: [], repair_count: 0, max_repair_count: 1, + hermes_revision: 0, + confirmed_dispatches: [], }; const record = { ...base, ...overrides }; if (overrides.subtasks !== undefined) { @@ -383,9 +471,25 @@ export function emptyTaskRecord( if (overrides.definition_of_done !== undefined) { record.definition_of_done = [...overrides.definition_of_done]; } + if (overrides.context_pins !== undefined) { + record.context_pins = [...overrides.context_pins]; + } + if (overrides.research_change_fields !== undefined) { + record.research_change_fields = [...overrides.research_change_fields]; + } + if (overrides.constraints !== undefined) { + record.constraints = { + excluded_platforms: [...overrides.constraints.excluded_platforms], + excluded_paths: [...overrides.constraints.excluded_paths], + validation_level: overrides.constraints.validation_level, + }; + } if (overrides.blockers !== undefined) { record.blockers = [...overrides.blockers]; } + if (overrides.confirmed_dispatches !== undefined) { + record.confirmed_dispatches = [...overrides.confirmed_dispatches]; + } if (overrides.work_packages !== undefined) { record.work_packages = overrides.work_packages.map((item) => ({ ...item, @@ -393,6 +497,12 @@ export function emptyTaskRecord( evidence_required: [...item.evidence_required], depends_on: [...item.depends_on], evidence_refs: [...item.evidence_refs], + ...(item.confirmed_dispatches !== undefined + ? { confirmed_dispatches: [...item.confirmed_dispatches] } + : {}), + ...(item.dispatch_blockers !== undefined + ? { dispatch_blockers: [...item.dispatch_blockers] } + : {}), })); } if (overrides.meta !== undefined) { @@ -427,7 +537,13 @@ function parseClosureWorkPackage( return value; }; const readStringArray = ( - field: "done_when" | "evidence_required" | "depends_on" | "evidence_refs", + field: + | "done_when" + | "evidence_required" + | "depends_on" + | "evidence_refs" + | "confirmed_dispatches" + | "dispatch_blockers", ): string[] => { const value = input[field]; if ( @@ -451,6 +567,18 @@ function parseClosureWorkPackage( `task.work_packages[${index}].blocker must be a string or null`, ); } + const hasOptionalField = (field: string): boolean => + Object.prototype.hasOwnProperty.call(input, field); + const dispatchState = input.dispatch_blocked_from_status; + if ( + hasOptionalField("dispatch_blocked_from_status") && + dispatchState !== "running" && + dispatchState !== "review" + ) { + throw new Error( + `task.work_packages[${index}].dispatch_blocked_from_status must be running or review`, + ); + } return { id: readString("id"), title: readString("title"), @@ -461,6 +589,16 @@ function parseClosureWorkPackage( status: status as ClosureWorkPackageStatus, evidence_refs: readStringArray("evidence_refs"), blocker, + confirmed_dispatches: hasOptionalField("confirmed_dispatches") + ? readStringArray("confirmed_dispatches") + : undefined, + dispatch_blockers: hasOptionalField("dispatch_blockers") + ? readStringArray("dispatch_blockers") + : undefined, + dispatch_blocked_from_status: dispatchState as + | "running" + | "review" + | undefined, }; } diff --git a/packages/core/test/task/schema.test.ts b/packages/core/test/task/schema.test.ts index 209ab11..2234501 100644 --- a/packages/core/test/task/schema.test.ts +++ b/packages/core/test/task/schema.test.ts @@ -25,7 +25,17 @@ describe("emptyTaskRecord", () => { expect(record.closure_state).toBe("open"); expect(record.closure_mode).toBe("lean"); expect(record.work_packages).toEqual([]); + expect(record.context_pins).toEqual([]); + expect(record.research_route).toBe("delivery"); + expect(record.research_change_fields).toEqual([]); + expect(record.grill_completed).toBe(false); + expect(record.constraints).toEqual({ + excluded_platforms: [], + excluded_paths: [], + validation_level: "targeted", + }); expect(record.max_repair_count).toBe(1); + expect(record.hermes_revision).toBe(0); expect(record.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); @@ -107,12 +117,18 @@ describe("taskRecordSchema", () => { "in_scope", "out_of_scope", "definition_of_done", + "context_pins", + "research_route", + "research_change_fields", + "grill_completed", + "constraints", "work_packages", "current_work_package", "next_action", "blockers", "repair_count", "max_repair_count", + "hermes_revision", ]); const legacy = Object.fromEntries( Object.entries(emptyTaskRecord()).filter( @@ -176,6 +192,45 @@ describe("taskRecordSchema", () => { hermes_phase: "complete", }), ).toThrow(/hermes_phase is invalid/); + expect(() => + taskRecordSchema.parse({ + ...emptyTaskRecord(), + hermes_revision: -1, + }), + ).toThrow(/hermes_revision must be a non-negative integer/); + const invalidDispatchField = emptyTaskRecord({ + work_packages: [ + { + id: "WP1", + title: "Invalid dispatch fields", + outcome: "Result exists", + done_when: ["Result exists"], + evidence_required: [], + depends_on: [], + status: "ready", + evidence_refs: [], + blocker: null, + }, + ], + }); + const invalidPackage = invalidDispatchField.work_packages?.[0] as Record< + string, + unknown + >; + invalidPackage.confirmed_dispatches = "job-1"; + expect(() => taskRecordSchema.parse(invalidDispatchField)).toThrow( + /confirmed_dispatches must be an array of strings/, + ); + invalidPackage.confirmed_dispatches = []; + invalidPackage.dispatch_blockers = "job-1"; + expect(() => taskRecordSchema.parse(invalidDispatchField)).toThrow( + /dispatch_blockers must be an array of strings/, + ); + invalidPackage.dispatch_blockers = ["job-1"]; + invalidPackage.dispatch_blocked_from_status = "done"; + expect(() => taskRecordSchema.parse(invalidDispatchField)).toThrow( + /dispatch_blocked_from_status must be running or review/, + ); }); it("rejects records missing canonical fields", () => {