From 6d66810fdeb01abef54dd91dfa6259fab5275741 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Tue, 21 Jul 2026 10:53:38 +0800 Subject: [PATCH 01/13] feat(hermes): add role profiles and context firewall --- marketplace/workflows/native/workflow.md | 28 +- packages/cli/package.json | 2 +- packages/cli/scripts/hermes-preflight.js | 15 +- packages/cli/src/commands/init.ts | 255 ++- packages/cli/src/commands/update.ts | 70 +- packages/cli/src/configurators/workflow.ts | 42 +- packages/cli/src/constants/paths.ts | 4 + .../migrations/manifests/0.7.1-beta.0.json | 9 + .../claude/agents/hermes-claim-reviewer.md | 42 - .../templates/claude/agents/hermes-coder.md | 38 +- .../claude/agents/hermes-evaluator.md | 37 - .../claude/agents/hermes-literature.md | 36 - .../templates/claude/agents/hermes-planner.md | 12 + .../claude/agents/hermes-researcher.md | 12 + .../claude/agents/hermes-reviewer.md | 35 +- .../templates/claude/agents/hermes-runner.md | 35 +- .../claude/agents/hermes-scientist.md | 43 - .../cli/src/templates/claude/settings.json | 34 + .../templates/codex/agents/hermes-coder.toml | 17 + .../codex/agents/hermes-planner.toml | 17 + .../codex/agents/hermes-researcher.toml | 17 + .../codex/agents/hermes-reviewer.toml | 17 + .../templates/codex/agents/hermes-runner.toml | 17 + packages/cli/src/templates/codex/config.toml | 20 +- .../templates/codex/hooks/session-start.py | 18 +- .../bundled-skills/hermes-research/SKILL.md | 45 +- .../src/templates/common/commands/start.md | 2 +- .../src/templates/common/skills/brainstorm.md | 12 +- packages/cli/src/templates/markdown/index.ts | 27 + .../guides/general-code-guidelines.md.txt | 89 + .../markdown/spec/guides/index.md.txt | 9 + .../markdown/spec/languages/cpp.md.txt | 23 + .../markdown/spec/languages/go.md.txt | 22 + .../markdown/spec/languages/index.md.txt | 21 + .../markdown/spec/languages/python.md.txt | 24 + .../markdown/spec/languages/rust.md.txt | 23 + .../markdown/spec/languages/shell.md.txt | 22 + .../languages/typescript-javascript.md.txt | 24 + .../shared-hooks/hermes-runtime-guard.py | 581 ++++- .../shared-hooks/inject-subagent-context.py | 280 ++- .../shared-hooks/inject-workflow-state.py | 127 +- .../templates/shared-hooks/session-start.py | 61 +- .../cli/src/templates/trellis/config.yaml | 11 + .../hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md | 113 +- .../src/templates/trellis/hermes/config.yaml | 23 + .../trellis/hermes/records/recordbus.md | 60 +- .../hermes/records/subagent_protocol.md | 64 +- .../templates/trellis/hermes/roles/base.md | 10 + .../trellis/hermes/roles/claim-reviewer.md | 25 - .../templates/trellis/hermes/roles/coder.md | 42 +- .../trellis/hermes/roles/evaluator.md | 27 - .../trellis/hermes/roles/evidence-curator.md | 25 - .../trellis/hermes/roles/literature.md | 24 - .../templates/trellis/hermes/roles/planner.md | 34 + .../trellis/hermes/roles/researcher.md | 34 + .../trellis/hermes/roles/reviewer.md | 46 +- .../templates/trellis/hermes/roles/runner.md | 43 +- .../trellis/hermes/roles/scientist.md | 26 - .../schemas/result-envelope.schema.json | 51 + packages/cli/src/templates/trellis/index.ts | 14 + .../templates/trellis/project/BACKGROUND.md | 17 + .../templates/trellis/project/CONSTRAINTS.md | 17 + .../src/templates/trellis/project/README.md | 20 + .../trellis/project/RESEARCH_PLAN.md | 16 + .../src/templates/trellis/scripts/closure.py | 40 + .../trellis/scripts/common/closure.py | 1020 ++++++++- .../trellis/scripts/common/dispatch.py | 2033 +++++++++++++++++ .../trellis/scripts/common/firewall.py | 232 ++ .../templates/trellis/scripts/common/io.py | 118 + .../templates/trellis/scripts/common/roles.py | 224 ++ .../trellis/scripts/common/task_store.py | 37 +- .../templates/trellis/scripts/common/types.py | 7 + .../trellis/scripts/common/worktree_sync.py | 1 + .../trellis/scripts/hermes/dispatch.py | 232 ++ .../trellis/scripts/hermes/evidence.py | 212 ++ .../trellis/scripts/hermes/record.py | 16 + .../trellis/scripts/hermes/runtime.py | 58 +- .../trellis/scripts/hermes/validate.py | 5 +- .../cli/src/templates/trellis/workflow.md | 28 +- packages/cli/src/utils/project-context.ts | 211 ++ packages/core/LICENSE | 235 ++ packages/core/README.md | 290 +++ packages/core/package.json | 2 +- packages/core/src/task/index.ts | 2 + packages/core/src/task/schema.ts | 144 +- 85 files changed, 7451 insertions(+), 702 deletions(-) create mode 100644 packages/cli/src/migrations/manifests/0.7.1-beta.0.json delete mode 100644 packages/cli/src/templates/claude/agents/hermes-claim-reviewer.md delete mode 100644 packages/cli/src/templates/claude/agents/hermes-evaluator.md delete mode 100644 packages/cli/src/templates/claude/agents/hermes-literature.md create mode 100644 packages/cli/src/templates/claude/agents/hermes-planner.md create mode 100644 packages/cli/src/templates/claude/agents/hermes-researcher.md delete mode 100644 packages/cli/src/templates/claude/agents/hermes-scientist.md create mode 100644 packages/cli/src/templates/codex/agents/hermes-coder.toml create mode 100644 packages/cli/src/templates/codex/agents/hermes-planner.toml create mode 100644 packages/cli/src/templates/codex/agents/hermes-researcher.toml create mode 100644 packages/cli/src/templates/codex/agents/hermes-reviewer.toml create mode 100644 packages/cli/src/templates/codex/agents/hermes-runner.toml create mode 100644 packages/cli/src/templates/markdown/spec/guides/general-code-guidelines.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/cpp.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/go.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/index.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/python.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/rust.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/shell.md.txt create mode 100644 packages/cli/src/templates/markdown/spec/languages/typescript-javascript.md.txt create mode 100644 packages/cli/src/templates/trellis/hermes/roles/base.md delete mode 100644 packages/cli/src/templates/trellis/hermes/roles/claim-reviewer.md delete mode 100644 packages/cli/src/templates/trellis/hermes/roles/evaluator.md delete mode 100644 packages/cli/src/templates/trellis/hermes/roles/evidence-curator.md delete mode 100644 packages/cli/src/templates/trellis/hermes/roles/literature.md create mode 100644 packages/cli/src/templates/trellis/hermes/roles/planner.md create mode 100644 packages/cli/src/templates/trellis/hermes/roles/researcher.md delete mode 100644 packages/cli/src/templates/trellis/hermes/roles/scientist.md create mode 100644 packages/cli/src/templates/trellis/hermes/schemas/result-envelope.schema.json create mode 100644 packages/cli/src/templates/trellis/project/BACKGROUND.md create mode 100644 packages/cli/src/templates/trellis/project/CONSTRAINTS.md create mode 100644 packages/cli/src/templates/trellis/project/README.md create mode 100644 packages/cli/src/templates/trellis/project/RESEARCH_PLAN.md create mode 100644 packages/cli/src/templates/trellis/scripts/common/dispatch.py create mode 100644 packages/cli/src/templates/trellis/scripts/common/firewall.py create mode 100644 packages/cli/src/templates/trellis/scripts/common/roles.py create mode 100644 packages/cli/src/templates/trellis/scripts/hermes/dispatch.py create mode 100644 packages/cli/src/templates/trellis/scripts/hermes/evidence.py create mode 100644 packages/cli/src/utils/project-context.ts create mode 100644 packages/core/LICENSE create mode 100644 packages/core/README.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/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/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, }; } From d99622ca6c0271f11ab4037e32353d695aa95484 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Tue, 21 Jul 2026 10:54:24 +0800 Subject: [PATCH 02/13] test(hermes): cover closure roles and firewall --- ...lank-bootstrap-closure.integration.test.ts | 104 ++ .../test/commands/init.integration.test.ts | 118 +- .../test/commands/update.integration.test.ts | 44 +- packages/cli/test/constants/paths.test.ts | 5 + packages/cli/test/regression.test.ts | 9 +- ...agent-context-firewall.integration.test.ts | 1117 +++++++++++++++++ .../test/scripts/closure.integration.test.ts | 428 ++++++- packages/cli/test/templates/claude.test.ts | 85 +- packages/cli/test/templates/codex.test.ts | 94 +- .../cli/test/templates/hermes-roles.test.ts | 269 ++++ .../cli/test/templates/shared-hooks.test.ts | 270 +++- packages/cli/test/templates/trellis.test.ts | 77 +- packages/core/test/task/schema.test.ts | 55 + 13 files changed, 2578 insertions(+), 97 deletions(-) create mode 100644 packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts create mode 100644 packages/cli/test/scripts/agent-context-firewall.integration.test.ts create mode 100644 packages/cli/test/templates/hermes-roles.test.ts 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/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", () => { From d7dc8b925278ac208e74e294e0f079e66db1909f Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Tue, 21 Jul 2026 10:54:56 +0800 Subject: [PATCH 03/13] docs(hermes): document closure and role migration --- README.md | 16 ++- docs/hermes_deployment_preflight.md | 8 +- docs/hermes_role_profile_migration.md | 55 +++++++++ ...es_trellis_research_transformation_plan.md | 23 ++-- docs/manual/01-overview.md | 4 +- docs/manual/02-installation.md | 12 +- docs/manual/03-first-research-task.md | 4 +- docs/manual/04-how-it-works.md | 2 +- docs/manual/05-project-setup.md | 17 ++- docs/manual/06-tasks-and-planning.md | 2 +- docs/manual/07-spec-and-context.md | 12 +- docs/manual/08-hermes-records.md | 2 +- docs/manual/09-experiments.md | 2 +- docs/manual/10-runs-and-replay.md | 2 +- docs/manual/11-metrics-and-comparison.md | 2 +- docs/manual/12-reports-and-claims.md | 2 +- docs/manual/13-workers-and-gates.md | 112 +++++++++++++++--- docs/manual/14-workflows-and-platforms.md | 14 ++- docs/manual/15-channel-and-memory.md | 10 +- .../16-update-recovery-and-uninstall.md | 2 +- docs/manual/17-lean-research-closure.md | 40 ++++++- docs/manual/README.md | 2 +- docs/manual/appendix-a-cli-reference.md | 23 +++- docs/manual/appendix-b-directory-reference.md | 18 ++- .../appendix-c-configuration-reference.md | 23 +++- docs/manual/appendix-d-record-schema.md | 15 ++- docs/manual/appendix-e-troubleshooting.md | 15 ++- docs/manual/appendix-f-feature-status.md | 11 +- docs/manual/sources.md | 2 +- packages/cli/README.md | 16 ++- 30 files changed, 379 insertions(+), 89 deletions(-) create mode 100644 docs/hermes_role_profile_migration.md 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/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、门禁文档、沙箱配置、模板测试、类型检查和构建。 From a856f70d53e3fa7b8b9bf73e1ab64bee846b11d4 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Wed, 22 Jul 2026 00:36:15 +0800 Subject: [PATCH 04/13] fix(hermes): harden handoff and context recovery --- .github/workflows/ci.yml | 6 + docs/manual/14-workflows-and-platforms.md | 20 ++- docs/manual/17-lean-research-closure.md | 8 +- docs/manual/appendix-a-cli-reference.md | 14 +- marketplace/workflows/native/workflow.md | 6 + packages/cli/scripts/release-preflight.js | 10 ++ packages/cli/src/configurators/shared.ts | 17 +- .../src/templates/common/commands/handoff.md | 38 +++++ .../src/templates/common/commands/status.md | 30 ++++ .../src/templates/common/skills/grill-me.md | 4 +- .../templates/common/skills/update-spec.md | 5 + .../shared-hooks/hermes-runtime-guard.py | 5 +- .../shared-hooks/inject-workflow-state.py | 123 +++++++++++++- .../hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md | 20 +++ .../src/templates/trellis/scripts/closure.py | 5 +- .../trellis/scripts/common/closure.py | 112 +++++++++---- .../trellis/scripts/common/dispatch.py | 94 ++++++++++- .../cli/src/templates/trellis/scripts/task.py | 41 +++-- .../cli/src/templates/trellis/workflow.md | 6 + .../test/commands/release-preflight.test.ts | 10 ++ .../cli/test/configurators/shared.test.ts | 14 ++ ...agent-context-firewall.integration.test.ts | 39 +++++ .../test/scripts/closure.integration.test.ts | 115 ++++++++++++- .../cli/test/templates/shared-hooks.test.ts | 152 +++++++++++++++++- packages/cli/test/templates/trellis.test.ts | 8 + 25 files changed, 821 insertions(+), 81 deletions(-) create mode 100644 packages/cli/src/templates/common/commands/handoff.md create mode 100644 packages/cli/src/templates/common/commands/status.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c778d9..0d324fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,13 @@ jobs: "dist/templates/trellis/scripts/hermes/experiment.py", "dist/templates/trellis/scripts/hermes/runner.py", "dist/templates/trellis/scripts/hermes/report.py", + "dist/templates/trellis/scripts/common/closure.py", + "dist/templates/trellis/scripts/common/dispatch.py", + "dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "dist/templates/shared-hooks/hermes-runtime-guard.py", + "dist/templates/shared-hooks/inject-workflow-state.py", + "dist/templates/common/commands/status.md", + "dist/templates/common/commands/handoff.md", ]; const missingCliFiles = requiredCliFiles.filter( (file) => !fs.existsSync(path.join(cliRoot, file)), diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 2ed39be..7c098ba 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -69,10 +69,26 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会把 Hermes 正式角色升级为硬门禁。 -`v0.7.1`(当前版本)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`v0.7.1-beta.0`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)依赖已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex native`(Codex 原生代理)仅为 `advisory`(建议性),`Codex strict`(Codex 严格模式)通过 `codex exec --output-schema --json -o`(结构化执行参数)执行。能力缺失时,`lean`(轻量)警告降级,`standard`(标准)必须有钩子或严格模式,`publication`(发表)拒绝派发和关闭。 +## 用户入口 + +科研版只保留少量用户可见入口。主代理先根据任务状态、当前工作包和自然语言请求自动路由;命令和技能只是明确入口或兼容入口,不能绕过状态、审计和人工决策门禁。 + +| 入口 | 用途 | 说明 | +| --- | --- | --- | +| `continue`(继续任务) | 恢复活动任务 | 读取紧凑任务状态并只推进下一动作 | +| `status`(查看状态) | 查看阶段、工作包、阻塞和下一动作 | 只读,不会改变任务状态 | +| `handoff`(写入交接) | 在暂停、切换会话或压缩上下文前生成交接摘要 | 由受限子代理写入 `HANDOFF.md`(交接摘要),不会关闭任务 | +| `finish-work`(完成收尾) | 审计、关闭并归档已完成任务 | closure 任务必须先通过审计和关闭门禁 | +| `start`(启动会话) | 兼容没有会话开始钩子的环境 | 支持钩子的主会话通常自动完成启动 | + +`Claude Code`(Claude 代码工具)和其他原生命令平台会把这些入口显示为平台的命令形式;`Codex`(代码代理平台)使用同名技能入口。命令前缀由平台决定,不应把某个平台的前缀复制到另一个平台。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于用户明确要求讨论研究设计、架构或范围,后者用于用户明确要求把已验证经验写入规范;主代理在相同条件出现时也应自动触发对应过程。为保持旧项目兼容和自动路由,平台仍会安装 `brainstorm`(需求讨论)、`before-dev`(开发前准备)、`check`(检查)、`tdd`(测试驱动)、`break-loop`(重复问题分析)和架构审查等内部技能;它们不是日常需要记忆的入口,也不会替代 closure 的状态、审计和关闭门禁。 + 6. 逐平台能力和限制如下。“子代理”表示仓库会安装对应代理模板;“门禁”专指 `hermes-runtime-guard.py`(Hermes 运行时门禁),不包括只做上下文注入的同名钩子事件。 | 平台 | 主会话上下文 | 子代理与上下文 | `PreToolUse / Stop`(门禁) | 启用条件与限制 | @@ -106,7 +122,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并区分 Codex native 与 strict。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 1969681..fbeeb26 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -153,12 +153,14 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## Handoff -```bash -python3 ./.trellis/scripts/closure.py handoff --task "$TASK" -``` +`handoff`(交接)入口会创建一个受限的 `coder:configuration`(配置修改)子代理派发。主代理不直接运行写入命令;子代理只能生成当前任务的 `HANDOFF.md`,其结果确认后由确定性代码写入最终版本。 `HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 +有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。`HANDOFF.md`(交接摘要)会写入对应的任务修订号;只有修订号匹配时才提示读取一次,旧修订的交接会明确标记为过期而不会被使用。接收到 `compact`(上下文压缩)或 `clear`(清理上下文)事件时,会强制重注入任务胶囊;交接刷新也会触发一次重注入。交接文件全文不会自动注入,也不会默认传给子代理。 + +上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 + ## 常见失败 - `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index ef1b0d3..3649040 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -39,6 +39,18 @@ python3 ./.trellis/scripts/task.py finish python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ``` +平台命令入口: + +| 入口 | 用途 | 任务状态影响 | +| --- | --- | --- | +| `continue`(继续) | 恢复活动任务 | 无直接写入 | +| `status`(状态) | 显示阶段、工作包、阻塞和下一动作 | 只读 | +| `handoff`(交接) | 派发受限子代理写入 `HANDOFF.md`(交接摘要) | 只更新交接文件 | +| `finish-work`(收尾) | 审计、关闭并归档满足条件的任务 | 可能归档 | +| `start`(启动) | 兼容无自动启动钩子的环境 | 无直接写入 | + +平台决定命令前缀。`Claude Code`(Claude 代码工具)等原生命令平台使用斜杠命令;`Codex`(代码代理平台)使用同名技能入口。`status`(状态)不会运行修复、关闭或归档;`handoff`(交接)不会完成或关闭任务。 + Closure 命令: ```bash @@ -55,7 +67,7 @@ python3 ./.trellis/scripts/closure.py package-block --task "$TASK" --reason "阻 python3 ./.trellis/scripts/closure.py amend --task "$TASK" --field --value --reason python3 ./.trellis/scripts/closure.py audit --task "$TASK" python3 ./.trellis/scripts/closure.py repair --task "$TASK" -python3 ./.trellis/scripts/closure.py handoff --task "$TASK" +python3 ./.trellis/scripts/closure.py handoff --task "$TASK" # 仅交接子代理执行 python3 ./.trellis/scripts/closure.py close --task "$TASK" ``` diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index f2fd9ee..8ba9f09 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -220,6 +220,12 @@ Complex task: ask the user if you can create a Trellis task and enter the planni 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] +### 用户入口优先级 + +先根据活动任务、当前工作包、任务胶囊和用户的自然语言请求自动选择规划、执行、审核、交接或收尾流程;不要等待用户输入固定命令。`continue`(继续)、`status`(状态)、`handoff`(交接)、`finish-work`(收尾)和 `start`(启动)只是明确入口或兼容入口,不能绕过任何状态门禁。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) diff --git a/packages/cli/scripts/release-preflight.js b/packages/cli/scripts/release-preflight.js index 0d802a3..2a89659 100644 --- a/packages/cli/scripts/release-preflight.js +++ b/packages/cli/scripts/release-preflight.js @@ -77,7 +77,11 @@ const HERMES_PYTHON_TEMPLATE_FILES = [ "packages/cli/src/templates/trellis/scripts/hermes/runner.py", "packages/cli/src/templates/trellis/scripts/hermes/runtime.py", "packages/cli/src/templates/trellis/scripts/hermes/validate.py", + "packages/cli/src/templates/trellis/scripts/common/closure.py", + "packages/cli/src/templates/trellis/scripts/common/dispatch.py", + "packages/cli/src/templates/trellis/scripts/common/firewall.py", "packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py", + "packages/cli/src/templates/shared-hooks/inject-workflow-state.py", ]; const PACKED_CLI_REQUIRED_FILES = [ "package/README.md", @@ -89,7 +93,13 @@ const PACKED_CLI_REQUIRED_FILES = [ "package/dist/templates/trellis/scripts/hermes/experiment.py", "package/dist/templates/trellis/scripts/hermes/runner.py", "package/dist/templates/trellis/scripts/hermes/report.py", + "package/dist/templates/trellis/scripts/common/closure.py", + "package/dist/templates/trellis/scripts/common/dispatch.py", + "package/dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "package/dist/templates/shared-hooks/hermes-runtime-guard.py", + "package/dist/templates/shared-hooks/inject-workflow-state.py", + "package/dist/templates/common/commands/status.md", + "package/dist/templates/common/commands/handoff.md", ]; const RED = "\x1b[31m"; diff --git a/packages/cli/src/configurators/shared.ts b/packages/cli/src/configurators/shared.ts index 9d81d8a..fb65b25 100644 --- a/packages/cli/src/configurators/shared.ts +++ b/packages/cli/src/configurators/shared.ts @@ -229,6 +229,10 @@ const SKILL_DESCRIPTIONS: Record = { "Resume work on the current task. Loads the workflow Phase Index, figures out which phase/step to pick up at, then pulls the step-level detail via get_context.py --mode phase. Use when coming back to an in-progress task and you need to know what to do next.", "finish-work": "Wrap up the current session: verify quality gate passed, remind user to commit, archive completed tasks, and record session progress to the developer journal. Use when done coding and ready to end the session.", + status: + "Show the active task's compact phase, work package, blockers, and next action without changing task state. Use when the user asks for progress or wants to confirm what happens next.", + handoff: + "Write a compact HANDOFF.md checkpoint for the active Hermes closure task before a pause, context compaction, session switch, or explicit handoff request. It does not close or archive the task.", "before-dev": "Discovers and injects project-specific coding guidelines from .trellis/spec/ before implementation begins. Reads spec indexes, pre-development checklists, and shared thinking guides for the target package. Use when starting a new coding task, before writing any code, switching to a different package, or needing to refresh project conventions and standards.", brainstorm: @@ -237,8 +241,7 @@ const SKILL_DESCRIPTIONS: Record = { "Tightens a Trellis task's requirements after repository-first clarification. Use when factual questions are exhausted and the remaining gaps are about scope, product intent, trade-offs, or edge cases that must be resolved before implementation.", check: "Comprehensive quality verification: spec compliance, lint, type-check, tests, cross-layer data flow, code reuse, and consistency checks. Use when code is written and needs quality verification, before committing changes, or to catch context drift during long sessions.", - tdd: - "Drives a test-first implementation flow by breaking work into observable behavior slices, defining public interfaces under test, and running red-green-refactor cycles. Use when the chosen development strategy is TDD or the user explicitly asks to work test-first.", + tdd: "Drives a test-first implementation flow by breaking work into observable behavior slices, defining public interfaces under test, and running red-green-refactor cycles. Use when the chosen development strategy is TDD or the user explicitly asks to work test-first.", "break-loop": "Deep bug analysis to break the fix-forget-repeat cycle. Analyzes root cause category, why fixes failed, prevention mechanisms, and captures knowledge into specs. Use after fixing a bug to prevent the same class of bugs.", "improve-codebase-architecture": @@ -273,6 +276,8 @@ export function wrapWithSkillFrontmatter( const COMMAND_DESCRIPTIONS: Record = { start: "Initialize a Trellis development session.", continue: "Resume work on the current task at the correct phase.", + status: "Show the active task status without changing it.", + handoff: "Write a compact handoff for the active closure task.", "finish-work": "Wrap up the current session: quality gate, commit reminder, archive, journal.", }; @@ -292,7 +297,8 @@ export function wrapWithCommandFrontmatter( return `---\nname: ${name}\ndescription: ${description}\n---\n\n${content}`; } -const TRELLIS_SWITCH_GUARD_PATH = "./.trellis/scripts/assert_trellis_enabled.py"; +const TRELLIS_SWITCH_GUARD_PATH = + "./.trellis/scripts/assert_trellis_enabled.py"; const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---)((?:\r?\n)*)/; const TOP_LEVEL_HEADING_RE = /^(# [^\r\n]+)((?:\r?\n)+)/; @@ -539,7 +545,10 @@ export function resolveBundledSkills( relativePath: `${skill.name}/${file.relativePath}`, content: file.relativePath === "SKILL.md" - ? injectTrellisSwitchGuard(resolvePlaceholders(file.content, ctx), "skill") + ? injectTrellisSwitchGuard( + resolvePlaceholders(file.content, ctx), + "skill", + ) : resolvePlaceholders(file.content, ctx), })), ); diff --git a/packages/cli/src/templates/common/commands/handoff.md b/packages/cli/src/templates/common/commands/handoff.md new file mode 100644 index 0000000..cbe2b19 --- /dev/null +++ b/packages/cli/src/templates/common/commands/handoff.md @@ -0,0 +1,38 @@ +# Write Task Handoff + +Write a concise handoff for the active Hermes closure task before a pause, session switch, context compaction, or explicit user handoff request. + +## Step 1: Resolve the active task + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/task.py current --source +``` + +If there is no active task, report that fact and stop. + +## Step 2: Dispatch the handoff writer + +The main agent must not run the write command. Create a validated +`coder:configuration` dispatch with no work package, only this allowed file: + +```text +.trellis/tasks//HANDOFF.md +``` + +The worker receives the current `task.json` and runs +`closure.py handoff --task `. It returns a normal Result Envelope. After +that result is confirmed, the deterministic closure code rewrites the handoff +with the new task revision. + +Read the sanitized worker result, then report the generated `HANDOFF.md` path, +current phase, current work package, blockers, and next action. Keep the chat +response short; the file is the durable checkpoint. + +For a legacy task without closure fields, do not invent a Hermes handoff. Report that the task uses the original compatibility flow and identify the current task artifacts instead. + +## Boundaries + +- Writing a handoff does not complete, close, archive, approve, or amend the task. +- Do not load full chat history or unrelated task files to create the handoff. +- The dedicated handoff writer is the one exception: it may only write the + current task's `HANDOFF.md` and must not change task state or other files. diff --git a/packages/cli/src/templates/common/commands/status.md b/packages/cli/src/templates/common/commands/status.md new file mode 100644 index 0000000..7b92f37 --- /dev/null +++ b/packages/cli/src/templates/common/commands/status.md @@ -0,0 +1,30 @@ +# Show Task Status + +Show the current Trellis task without changing task, evidence, claim, or archive state. + +## Step 1: Resolve the active task + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/task.py current --source +``` + +If there is no active task, report that fact and stop. Do not create a task, start a task, or dispatch a subagent. + +## Step 2: Read the smallest authoritative status + +For a Hermes closure task, use: + +```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py status --task +{{PYTHON_CMD}} ./.trellis/scripts/closure.py next --task +``` + +Report only the task title, phase, current work package, next action, blockers, and whether `HANDOFF.md` exists. Do not load the handoff body, full PRD, reports, or event history unless the user asks for that specific detail. + +For a legacy task without closure fields, report its original task status and the available task artifacts. Preserve the original Trellis compatibility path. + +## Boundaries + +- This command is read-only. +- Do not run plan, validate, package-start, repair, close, finish, or archive. +- Do not turn a runner result into accepted evidence or a completed task. diff --git a/packages/cli/src/templates/common/skills/grill-me.md b/packages/cli/src/templates/common/skills/grill-me.md index d605cf5..35fe815 100644 --- a/packages/cli/src/templates/common/skills/grill-me.md +++ b/packages/cli/src/templates/common/skills/grill-me.md @@ -25,7 +25,9 @@ Do **not** use this skill for questions the codebase can answer directly. - why it matters - your recommended answer - what trade-off the user accepts if they choose differently -- After each answer, update `prd.md` before asking the next question. +- After each answer, record the proposed `prd.md` update for a validated planner + or coder dispatch before asking the next question. A main agent must not edit + `prd.md` directly. - Stop once `prd.md` has converged enough to enter development-strategy decisions. ## Questioning Style diff --git a/packages/cli/src/templates/common/skills/update-spec.md b/packages/cli/src/templates/common/skills/update-spec.md index 19e4f1a..b6c845f 100644 --- a/packages/cli/src/templates/common/skills/update-spec.md +++ b/packages/cli/src/templates/common/skills/update-spec.md @@ -1,5 +1,10 @@ # Update Code-Spec - Capture Executable Contracts +In a Hermes closure task, the main agent may identify that this process is +needed, but it must delegate every file update through a validated +`coder:configuration` dispatch. The main agent only checks the returned +result and does not edit a spec directly. + When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. **Timing**: After completing a task, fixing a bug, or discovering a new pattern diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 80a3eff..2d5062b 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -756,7 +756,7 @@ def is_main_control_bash(command: str) -> bool: }, ".trellis/scripts/closure.py": { "status", "next", "capsule", "validate", "package-start", "package-check", - "package-done", "package-block", "audit", "repair", "amend", "handoff", "close", + "package-done", "package-block", "audit", "repair", "amend", "close", }, ".trellis/scripts/task.py": {"current", "archive", "finish"}, } @@ -964,6 +964,9 @@ def extract_bash_targets(root: Path, command: str) -> tuple[list[str], str | Non except ValueError as exc: return [], f"cannot safely parse Bash write targets: {exc}" + if any(command_basename(token) == "ln" for token in tokens): + return [], "link creation is not allowed for Hermes dispatches" + targets: list[str] = [] redirect_tokens = {">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", ">|"} redirect_prefix = re.compile(r"^(?:\d?>{1,2}|&>{1,2}|\|>)(.+)$") diff --git a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py index 1f1d93b..5618782 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -71,6 +71,8 @@ ("Project constraints", "CONSTRAINTS.md", "fixed boundaries and approvals"), ) +_HANDOFF_REVISION_RE = re.compile(r"") + def _build_project_context_index(root: Path) -> str: """List main-agent project inputs without loading the documents themselves.""" @@ -215,7 +217,76 @@ def get_task_capsule(root: Path, input_data: dict) -> str | None: return build_capsule(task_dir, task, root) -def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None] | None: +def _build_task_resume_notice( + root: Path, + task_dir: Path, + data: dict, + *, + task_id: str, + phase: str, + next_action: str, + handoff_notice: str | None, +) -> str: + """Point the main agent at durable task state on every active-task turn.""" + def compact(value: object, limit: int) -> str: + text = " ".join(str(value or "-").split()) + return text if len(text) <= limit else text[: limit - 3].rstrip() + "..." + + try: + task_ref = task_dir.relative_to(root).as_posix() + except ValueError: + task_ref = ".trellis/tasks/" + task_dir.name + task_label = compact(task_id, 72) + phase_label = compact(phase, 32) + current_package = compact(data.get("current_work_package"), 48) + action = compact(next_action, 180) + lines = [ + "", + f"Active task: {task_label}; phase={phase_label}; current package={current_package}; next={action}", + f"Before planning, dispatching, or continuing work, read {task_ref}/task.json.", + ] + if handoff_notice: + lines.append(handoff_notice) + lines.append( + "Read only the directly relevant referenced file for this request; do not preload project context, full task history, or unrelated artifacts." + ) + lines.append("") + return "\n".join(lines) + + +def _handoff_state(task_dir: Path, revision: object) -> tuple[str, str | None]: + """Return a fresh handoff marker or a compact stale/missing explanation.""" + handoff = task_dir / "HANDOFF.md" + if not handoff.is_file(): + return "missing", "No current HANDOFF.md exists; use the Task Capsule and task state." + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: + return "invalid-task-revision", "Ignore HANDOFF.md until task.json has a valid Hermes revision." + try: + prefix = handoff.read_text(encoding="utf-8", errors="replace")[:512] + marker = _HANDOFF_REVISION_RE.search(prefix) + stamp = handoff.stat().st_mtime_ns + except OSError: + return "unreadable", "HANDOFF.md could not be read; use task.json as the source of truth." + if marker is None or int(marker.group(1)) != revision: + return f"stale:{marker.group(1) if marker else 'legacy'}:{stamp}", "HANDOFF.md is stale for this task revision; do not rely on it. Regenerate it before a handoff." + return f"fresh:{revision}:{stamp}", None + + +def _context_reset_requested(input_data: dict) -> bool: + """Recognize compact/clear markers supplied by hook-capable hosts.""" + for key in ("source", "event", "event_type", "reason", "session_start_source"): + value = input_data.get(key) + if isinstance(value, str) and value.casefold() in { + "compact", + "compaction", + "context_compacted", + "clear", + }: + return True + return False + + +def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None, str] | None: """Return closure-specific, revision-aware per-turn context.""" active = _resolve_active_task(root, input_data) if not active.task_path or active.stale: @@ -257,12 +328,37 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None except (OSError, json.JSONDecodeError): anchor = {} previous = anchor.get("closure_context_anchor") if isinstance(anchor, dict) else None + context_reset = _context_reset_requested(input_data) + if context_reset: + previous = None + handoff_marker, handoff_problem = _handoff_state(task_dir, revision) + seen_handoff_marker = previous.get("handoff_marker") if isinstance(previous, dict) else None + if handoff_marker == seen_handoff_marker: + handoff_notice = None + elif handoff_problem: + handoff_notice = handoff_problem + else: + try: + handoff_ref = task_dir.relative_to(root).as_posix() + except ValueError: + handoff_ref = ".trellis/tasks/" + task_dir.name + handoff_notice = f"Read {handoff_ref}/HANDOFF.md once before continuing; it matches the current task revision." + resume_notice = _build_task_resume_notice( + root, + task_dir, + data, + task_id=task_id, + phase=phase, + next_action=next_action, + handoff_notice=handoff_notice, + ) current = { "task": task_id, "revision": revision, "phase": phase, "next_action": next_action, "route": route_summary, + "handoff_marker": handoff_marker, } if anchor_path: try: @@ -271,10 +367,19 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None anchor_path.write_text(json.dumps(anchor, ensure_ascii=False) + "\n", encoding="utf-8") except OSError: pass - if isinstance(previous, dict) and previous.get("task") == task_id and previous.get("revision") == revision: + force_capsule = context_reset or ( + handoff_marker.startswith("fresh:") and handoff_marker != seen_handoff_marker + ) + if ( + isinstance(previous, dict) + and previous.get("task") == task_id + and previous.get("revision") == revision + and not force_capsule + ): return ( f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\n", None, + resume_notice, ) if isinstance(previous, dict) and previous.get("task") == task_id: changes = [f"Hermes closure update: {task_id}; revision {previous.get('revision')} -> {revision}."] @@ -287,10 +392,12 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None return ( "\n" + "\n".join(changes) + "\n", build_capsule(task_dir, data, root), + resume_notice, ) return ( f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nNext: {next_action}\n", build_capsule(task_dir, data, root), + resume_notice, ) # --------------------------------------------------------------------------- # Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults @@ -492,8 +599,9 @@ def main() -> int: closure_context = _closure_turn_context(root, data) task = get_active_task(root, data) capsule = None + resume_notice = None if closure_context is not None: - breadcrumb, capsule = closure_context + breadcrumb, capsule, resume_notice = closure_context elif task is None: # No active task — still emit a breadcrumb nudging AI toward # trellis-brainstorm + task.py create when user describes real work. @@ -520,11 +628,16 @@ def main() -> int: parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE) parts.append(_codex_mode_banner(config)) parts.append(breadcrumb) + if resume_notice: + parts.append(resume_notice) if capsule: parts.append(f"\n{capsule}\n") breadcrumb = "\n\n".join(parts) - elif capsule: - breadcrumb = f"{breadcrumb}\n\n\n{capsule}\n" + else: + if resume_notice: + breadcrumb = f"{breadcrumb}\n\n{resume_notice}" + if capsule: + breadcrumb = f"{breadcrumb}\n\n\n{capsule}\n" # Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is # named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/ diff --git a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md index b76827a..438ddf1 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 @@ -49,6 +49,26 @@ context for its role and profile only after explicit task refs and context pins. Use the remaining refs for the task-specific spec or evidence that the worker needs. Do not add all project documents merely because they exist. +## 1.1 Routing Priority + +Use this order for every request: + +1. current `task.json`, validated closure state, Task Capsule, and RecordBus; +2. the user's natural-language intent and the current work-package boundary; +3. explicit command or skill requests as a fallback or an override of the + requested action, never as a way to bypass task state or gates. + +Do not wait for the user to invoke a command before planning, dispatching, +running review, recording a plan change, requesting a required handoff, or +closing a task. Route those actions from the current state. The user-facing +`grill-me` and `update-spec` skills remain optional explicit entry points, but +the main agent must also trigger their underlying process when the state calls +for focused decision resolution or durable project knowledge. It may lead the +user discussion itself, but any task or spec write must be delegated through a +validated planner or coder dispatch; the main agent only verifies the result. +`HANDOFF.md` is written only by the restricted `coder:configuration` handoff +dispatch, never by the main agent. + ## 2. Main Agent Hard Limits As Main Agent, you must not directly: diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index b134ca8..7bd0529 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -18,6 +18,7 @@ block_package, build_capsule, check_package, + closure_next_action, close_task, complete_package, format_audit_yaml, @@ -209,7 +210,7 @@ def main(argv: list[str] | None = None) -> int: print(build_capsule(task_dir, data, repo_root)) return 0 if args.command == "next": - print(data.get("next_action") or "Run closure.py plan or validate.") + print(closure_next_action(data)) return 0 if args.command == "capsule": print(build_capsule(task_dir, data, repo_root)) @@ -305,7 +306,7 @@ def _status_record(data: dict[str, Any]) -> dict[str, Any]: "closure_state": data.get("closure_state"), "closure_mode": data.get("closure_mode"), "current_work_package": current_package, - "next_action": data.get("next_action"), + "next_action": closure_next_action(data), "blockers": data.get("blockers") or [], "repair_count": data.get("repair_count", 0), "max_repair_count": data.get("max_repair_count", 1), diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index fc4ba24..5d02489 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -3,8 +3,8 @@ from __future__ import annotations import json +import hashlib import re -import subprocess import uuid from copy import deepcopy from datetime import datetime, timezone @@ -12,7 +12,7 @@ from typing import Any from .firewall import closure_mode_gate -from .io import file_lock, read_json, write_json_atomic, write_text_atomic +from .io import file_lock, path_has_symlink, 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 @@ -776,8 +776,9 @@ def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: if _string_list(data.get("blockers")): errors.append("unresolved blockers must be resolved before validation") - pins = _string_list(data.get("context_pins")) - if isinstance(data.get("context_pins"), list) and len(pins) != len(data.get("context_pins")): + raw_pins = data.get("context_pins") + pins = _string_list(raw_pins) + if isinstance(raw_pins, list) and len(pins) != len(raw_pins): errors.append("context_pins must be an array of non-empty strings") if len(pins) > 3: errors.append("context_pins supports at most 3 entries") @@ -1527,14 +1528,14 @@ def amend_plan( semantic_ids = [field.split(".", 2)[1]] affected_ids = _dedupe([*semantic_ids, *incomplete_ids]) human_approved = str(approved_by or "").casefold() in {"human", "root", "human/root"} + previous_route = research_route(data) + previous_changes = _research_change_fields(data) requires_independent_task = ( research_change == "model_architecture" and human_approved ) applied = not high_risk or human_approved if requires_independent_task: applied = False - previous_route = research_route(data) - previous_changes = _research_change_fields(data) data["research_route"] = "exploration" data["research_change_fields"] = _dedupe( [*previous_changes, "model_architecture"] @@ -1760,7 +1761,6 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: - repo_root = root or get_repo_root(task_dir) packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] completed = [str(item.get("id")) for item in packages if item.get("status") == "done"] evidence = _dedupe([ @@ -1768,10 +1768,14 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None for item in packages for ref in _string_list(item.get("evidence_refs")) ]) - changed = _git_changed_files(repo_root) + changed, integrity_warnings = _task_changed_file_records(task_dir, data) failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) + revision = data.get("hermes_revision", 0) + if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: + revision = 0 content = "\n".join( [ + f"", "# Task Handoff", "", "## Current Goal", @@ -1789,6 +1793,9 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None "## Files Changed", _markdown_items(changed), "", + "## Integrity Warnings", + _markdown_items(integrity_warnings), + "", "## Artifacts/Evidence", _markdown_items(evidence), "", @@ -1799,15 +1806,24 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None _markdown_items(_string_list(data.get("blockers"))), "", "## Next Action", - str(data.get("next_action") or "-"), + closure_next_action(data), "", "## Do Not Do", "- Do not widen scope or change research contracts without an approved amend event.", "", ] ) + path = handoff_path(task_dir) + if not write_text_atomic(path, content): + raise ClosureError("cannot write HANDOFF.md") + return path + + +def handoff_path(task_dir: Path) -> Path: + """Return a safe handoff destination without following symbolic links.""" path = task_dir / "HANDOFF.md" - path.write_text(content, encoding="utf-8") + if task_dir.is_symlink() or path_has_symlink(path, task_dir): + raise ClosureError("HANDOFF.md path crosses a symlink") return path @@ -2293,31 +2309,61 @@ def _recent_event_reasons(task_dir: Path, types: set[str]) -> list[str]: ][-5:] -def _git_changed_files(root: Path) -> list[str]: - try: - result = subprocess.run( - ["git", "status", "--short", "--untracked-files=all"], - cwd=root, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=3, - ) - except (OSError, subprocess.SubprocessError): - return [] +def _task_changed_files(task_dir: Path, data: dict[str, Any]) -> list[str]: + """Return only files from successful dispatches confirmed by task state.""" + return _task_changed_file_records(task_dir, data)[0] + + +def _task_changed_file_records(task_dir: Path, data: dict[str, Any]) -> tuple[list[str], list[str]]: + """Return verified changed files and compact warnings for rejected result records.""" files: list[str] = [] - for line in result.stdout.splitlines(): - value = line[3:].strip() if len(line) > 3 else "" - if " -> " in value: - value = value.split(" -> ", 1)[1] - lowered = value.casefold() - if value and not any( - marker in lowered - for marker in (".env", "credentials", "secret", "token", "api_key") + integrity_warnings: list[str] = [] + confirmed = set(_string_list(data.get("confirmed_dispatches"))) + current_revision = data.get("hermes_revision") + for result_path in sorted((task_dir / "hermes" / "dispatches").glob("*.result.json")): + value = read_json(result_path) + if not isinstance(value, dict): + continue + job_id = result_path.name.removesuffix(".result.json") + if value.get("job_id") != job_id or job_id not in confirmed: + continue + dispatch = read_json(task_dir / "hermes" / "dispatches" / f"{job_id}.dispatch.json") + confirmed_revision = dispatch.get("confirmed_revision") if isinstance(dispatch, dict) else None + if ( + not isinstance(dispatch, dict) + or dispatch.get("job_id") != job_id + or dispatch.get("status") != "confirmed" + or value.get("status") != "success" + or value.get("confirmed") is not True + or value.get("task_revision") != dispatch.get("hermes_revision") + or not isinstance(confirmed_revision, int) + or isinstance(confirmed_revision, bool) + or not isinstance(current_revision, int) + or isinstance(current_revision, bool) + or confirmed_revision > current_revision ): - files.append(value) - return files[:20] + continue + expected_hash = dispatch.get("result_sha256") + actual_hash = _result_sha256(value) + if not isinstance(expected_hash, str) or expected_hash != actual_hash: + integrity_warnings.append(f"{job_id}: confirmed result integrity check failed") + continue + for raw_path in _string_list(value.get("changed_files")): + normalized = raw_path.replace("\\", "/").removeprefix("./") + if ( + normalized + and not normalized.startswith("/") + and not re.match(r"^[A-Za-z]:/", normalized) + and ".." not in Path(normalized).parts + ): + files.append(normalized) + return _dedupe(files)[:20], _dedupe(integrity_warnings)[:5] + + +def _result_sha256(value: dict[str, Any]) -> str: + payload = {key: item for key, item in value.items() if key != "audit"} + serialized = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}" def _gap(package: str | None, missing: str, action: str) -> dict[str, Any]: diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index 885512b..cc4f47d 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -18,11 +18,13 @@ from .closure import ( ClosureError, closure_constraints, + handoff_path, is_closure_task, now_utc, package_by_id, save_task, task_state_lock, + write_handoff, ) from .firewall import closure_mode_gate, record_firewall_heartbeat from .io import path_has_symlink, read_json, write_json_atomic @@ -211,16 +213,25 @@ def create_dispatch( role = normalized.role profile = normalized.profile work_package = _normalize_optional_text(spec.get("work_package")) - _validate_work_package(task, role, work_package) + constraints = closure_constraints(task) + allowed_files = _normalize_patterns(spec.get("allowed_files"), "allowed_files") + forbidden_files = _normalize_patterns(spec.get("forbidden_files"), "forbidden_files") + handoff_writer = _is_handoff_writer( + task_dir, + repo_root, + role, + profile, + work_package, + allowed_files, + ) + _validate_handoff_file_boundary(task_dir, repo_root, allowed_files, handoff_writer) + _validate_work_package(task, role, work_package, allow_task_handoff=handoff_writer) refs = _normalize_refs( task_dir, repo_root, _merged_dispatch_refs(task, spec.get("refs"), repo_root, role, profile), role, ) - constraints = closure_constraints(task) - allowed_files = _normalize_patterns(spec.get("allowed_files"), "allowed_files") - forbidden_files = _normalize_patterns(spec.get("forbidden_files"), "forbidden_files") if role == "coder" and not allowed_files: raise DispatchError("missing_allowed_files", "coder dispatch requires allowed_files") if not forbidden_files: @@ -275,6 +286,7 @@ def create_dispatch( "role": role, "profile": profile, "work_package": work_package, + "handoff_writer": handoff_writer, "objective": objective, "allowed_refs": refs, "refs": refs, @@ -453,10 +465,27 @@ def validate_dispatch( except RoleProfileError: errors.append("role_profile") try: + handoff_writer = _is_handoff_writer( + task_dir, + repo_root, + str(dispatch.get("role") or ""), + str(dispatch.get("profile") or ""), + dispatch.get("work_package"), + _string_list(dispatch.get("allowed_files")), + ) + if dispatch.get("handoff_writer") is not handoff_writer: + errors.append("handoff_writer") + _validate_handoff_file_boundary( + task_dir, + repo_root, + _string_list(dispatch.get("allowed_files")), + handoff_writer, + ) _validate_work_package( task, str(dispatch.get("role") or ""), dispatch.get("work_package"), + allow_task_handoff=handoff_writer, ) except DispatchError as exc: errors.append(exc.code) @@ -562,7 +591,8 @@ def prepare_dispatch_for_agent( def build_canonical_prompt(dispatch: dict[str, Any]) -> str: refs = _string_list(dispatch.get("refs")) - constraints = dispatch.get("constraints") if isinstance(dispatch.get("constraints"), dict) else {} + raw_constraints = dispatch.get("constraints") + constraints: dict[str, Any] = raw_constraints if isinstance(raw_constraints, dict) else {} verification_level = constraints.get("validation_level", "targeted") excluded_platforms = _string_list(constraints.get("excluded_platforms")) excluded_paths = _string_list(constraints.get("excluded_paths")) @@ -587,6 +617,10 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: ] if dispatch.get("blind_review"): lines.append("Blind review: do not read coder/runner explanations or worker result prose; judge current artifacts and ledgers only.") + if dispatch.get("handoff_writer"): + lines.append( + "Handoff writer: run closure.py handoff for this task. Do not edit task state or other files; report HANDOFF.md as the only changed file." + ) body = "\n".join(lines) _check_sensitive_text(body, "dispatch body") if ABSOLUTE_USER_PATH_RE.search(body): @@ -632,6 +666,8 @@ def accept_result_text( raise DispatchError("state_confirmation_failed", "cannot reload task state") validate_dispatch(task_dir, current, dispatch, repo_root) sanitized = _sanitized_result(dispatch, result, raw_text) + if dispatch.get("handoff_writer") and sanitized["status"] == "success": + handoff_path(task_dir) dispatch["status"] = "result_returned" dispatch["result_returned_at"] = now_utc() _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) @@ -641,6 +677,13 @@ def accept_result_text( _apply_result_state(updated_task, dispatch, sanitized) try: save_task(task_dir, updated_task, lock_held=True) + if dispatch.get("handoff_writer") and sanitized["status"] == "success": + dispatch["status"] = "confirmed" + dispatch["confirmed_revision"] = _task_revision(updated_task) + dispatch["confirmed_at"] = now_utc() + dispatch["result_sha256"] = sanitized["audit"]["result_sha256"] + _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) + write_handoff(task_dir, updated_task, repo_root) _append_worker_result(task_dir, dispatch, sanitized) except (ClosureError, DispatchError, OSError) as exc: raise DispatchError("state_confirmation_failed", "result was stored but state confirmation failed") from exc @@ -1678,10 +1721,47 @@ def _mark_dispatch_terminal( ) -def _validate_work_package(task: dict[str, Any], role: str, work_package: Any) -> None: +def _is_handoff_writer( + task_dir: Path, + repo_root: Path, + role: str, + profile: str, + work_package: Any, + allowed_files: list[str], +) -> bool: + expected = f"{_repo_relative(task_dir, repo_root)}/HANDOFF.md" + return ( + role == "coder" + and profile == "configuration" + and _normalize_optional_text(work_package) is None + and allowed_files == [expected] + ) + + +def _validate_handoff_file_boundary( + task_dir: Path, + repo_root: Path, + allowed_files: list[str], + handoff_writer: bool, +) -> None: + expected = f"{_repo_relative(task_dir, repo_root)}/HANDOFF.md" + if any(_path_matches(expected, pattern) for pattern in allowed_files) and not handoff_writer: + raise DispatchError( + "handoff_path_reserved", + "HANDOFF.md is reserved for the dedicated coder:configuration handoff dispatch", + ) + + +def _validate_work_package( + task: dict[str, Any], + role: str, + work_package: Any, + *, + allow_task_handoff: bool = False, +) -> None: current = task.get("current_work_package") normalized = _normalize_optional_text(work_package) - if role in EXECUTION_ROLES: + if role in EXECUTION_ROLES and not allow_task_handoff: if not normalized: raise DispatchError("missing_work_package", f"{role} dispatch requires work_package") if normalized != current: diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index f8e3107..bd5dbe1 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -24,6 +24,7 @@ from __future__ import annotations import argparse +import re import subprocess import sys @@ -293,34 +294,46 @@ def cmd_start(args: argparse.Namespace) -> int: def cmd_finish(args: argparse.Namespace) -> int: """Clear active task.""" repo_root = get_repo_root() - active = clear_active_task(repo_root) + active = resolve_active_task(repo_root) current = active.task_path if not current: print(colored("No current task set", Colors.YELLOW)) return 0 - # Resolve task.json path before clearing task_json_path = repo_root / current / FILE_TASK_JSON - print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) - print(f"Source: {active.source}") - if task_json_path.is_file(): task_data = read_json(task_json_path) if isinstance(task_data, dict): - from common.closure import is_closure_task, write_handoff + from common.closure import is_closure_task if is_closure_task(task_data) and task_data.get("closure_state") != "closed": - handoff_path = write_handoff( - task_json_path.parent, - task_data, - repo_root, + revision = task_data.get("hermes_revision") + handoff_path = task_json_path.parent / "HANDOFF.md" + fresh_handoff = ( + isinstance(revision, int) + and not isinstance(revision, bool) + and handoff_path.is_file() + and not handoff_path.is_symlink() + and re.search( + rf"", + handoff_path.read_text(encoding="utf-8", errors="replace")[:512], + ) is not None ) - print(colored( - f"✓ Handoff updated: {handoff_path.relative_to(repo_root)}", - Colors.GREEN, - )) + if not fresh_handoff: + print(colored( + "Open Hermes task requires a current HANDOFF.md from the dedicated handoff subagent before finish.", + Colors.RED, + )) + return 1 + + cleared = clear_active_task(repo_root) + + print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) + print(f"Source: {cleared.source}") + + if task_json_path.is_file(): run_task_hooks("after_finish", task_json_path, repo_root) return 0 diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index f2fd9ee..8ba9f09 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -220,6 +220,12 @@ Complex task: ask the user if you can create a Trellis task and enter the planni 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] +### 用户入口优先级 + +先根据活动任务、当前工作包、任务胶囊和用户的自然语言请求自动选择规划、执行、审核、交接或收尾流程;不要等待用户输入固定命令。`continue`(继续)、`status`(状态)、`handoff`(交接)、`finish-work`(收尾)和 `start`(启动)只是明确入口或兼容入口,不能绕过任何状态门禁。 + +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) diff --git a/packages/cli/test/commands/release-preflight.test.ts b/packages/cli/test/commands/release-preflight.test.ts index 4f493bb..d39b693 100644 --- a/packages/cli/test/commands/release-preflight.test.ts +++ b/packages/cli/test/commands/release-preflight.test.ts @@ -24,7 +24,13 @@ const packedCliRequiredFiles = [ "package/dist/templates/trellis/scripts/hermes/experiment.py", "package/dist/templates/trellis/scripts/hermes/runner.py", "package/dist/templates/trellis/scripts/hermes/report.py", + "package/dist/templates/trellis/scripts/common/closure.py", + "package/dist/templates/trellis/scripts/common/dispatch.py", + "package/dist/templates/trellis/hermes/schemas/result-envelope.schema.json", "package/dist/templates/shared-hooks/hermes-runtime-guard.py", + "package/dist/templates/shared-hooks/inject-workflow-state.py", + "package/dist/templates/common/commands/status.md", + "package/dist/templates/common/commands/handoff.md", ]; const packedCoreRequiredFiles = [ @@ -290,6 +296,10 @@ describe("release-preflight verify-packed-cli", () => { missingFile: "package/dist/templates/trellis/scripts/hermes/runner.py", }, { label: "COPYRIGHT", missingFile: "package/COPYRIGHT" }, + { + label: "the handoff command template", + missingFile: "package/dist/templates/common/commands/handoff.md", + }, ])("fails when packed CLI is missing $label", ({ missingFile }) => { const cliPkg = JSON.parse(fs.readFileSync(cliPkgPath, "utf-8")) as { name: string; diff --git a/packages/cli/test/configurators/shared.test.ts b/packages/cli/test/configurators/shared.test.ts index 1400b9e..d9fb1e8 100644 --- a/packages/cli/test/configurators/shared.test.ts +++ b/packages/cli/test/configurators/shared.test.ts @@ -564,6 +564,20 @@ describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { } }); + it("resolveCommands exposes compact status and handoff entry points", () => { + const commands = resolveCommands(claudeCtx); + const status = commands.find((command) => command.name === "status")?.content ?? ""; + const handoff = commands.find((command) => command.name === "handoff")?.content ?? ""; + const py = getPythonCommandForPlatform(); + + expect(status).toContain(`${py} ./.trellis/scripts/closure.py status --task `); + expect(status).toContain(`${py} ./.trellis/scripts/closure.py next --task `); + expect(status).toContain("This command is read-only"); + expect(handoff).toContain("coder:configuration"); + expect(handoff).toContain("must not run the write command"); + expect(handoff).toContain("does not complete, close, archive, approve, or amend"); + }); + it("resolveCommands injects the trellis-switch guard", () => { const commands = resolveCommands(claudeCtx); const guard = `${getPythonCommandForPlatform()} ./.trellis/scripts/assert_trellis_enabled.py`; diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index b7ab13a..d8cb460 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -250,6 +250,45 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.audit.metrics.ref_count).toBe(1); }); + it("permits a coder configuration dispatch only for the current task handoff", () => { + const result = run([ + "create", + "--task", "demo", + "--job-id", "job-handoff", + "--role", "coder", + "--profile", "configuration", + "--objective", "Write the current task handoff.", + "--ref", "prd.md", + "--allowed-file", ".trellis/tasks/demo/HANDOFF.md", + ]); + expect(result.status, result.stderr).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-handoff.dispatch.json"), + "utf-8", + ), + ) as { handoff_writer: boolean; work_package: string | null; body: string }; + expect(dispatch.handoff_writer).toBe(true); + expect(dispatch.work_package).toBeNull(); + expect(dispatch.body).toContain("Handoff writer:"); + + const applied = apply("job-handoff", validResult("job-handoff", { + changed_files: [".trellis/tasks/demo/HANDOFF.md"], + })); + expect(applied.status, applied.stderr).toBe(0); + const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); + expect(handoff).toContain(""); + expect(handoff).toContain("- .trellis/tasks/demo/HANDOFF.md"); + }); + + it("reserves the task handoff path for the dedicated handoff dispatch", () => { + const result = create("job-handoff-escape", "coder", [ + "--allowed-file", ".trellis/tasks/demo/HANDOFF.md", + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("handoff_path_reserved"); + }); + it("adds only role-matched project context after explicit task refs", () => { const projectDir = path.join(root, ".trellis", "project"); fs.mkdirSync(projectDir, { recursive: true }); diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index ca2ad32..1af183e 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -1,4 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -159,6 +160,27 @@ function writeTask(taskDir: string, value: Record): void { ); } +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, stableValue(item)]), + ); + } + return value; +} + +function resultSha256(value: Record): string { + const payload = Object.fromEntries( + Object.entries(value).filter(([key]) => key !== "audit"), + ); + return `sha256:${createHash("sha256") + .update(JSON.stringify(stableValue(payload)), "utf8") + .digest("hex")}`; +} + function plan(root: string, doneWhen: string[], extra: string[] = []) { const args = ["plan", "--intent", "Produce a verified result"]; for (const item of doneWhen) args.push("--done-when", item); @@ -609,7 +631,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); - it("writes a handoff when an open task session is finished", () => { + it("requires a current handoff before an open task session can finish", () => { expect(plan(root, ["Result A"]).status).toBe(0); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); fs.mkdirSync(sessions, { recursive: true }); @@ -617,10 +639,97 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { path.join(sessions, "closure-test-session.json"), `${JSON.stringify({ current_task: ".trellis/tasks/demo" })}\n`, ); + const blocked = runTask(root, "finish"); + expect(blocked.status).toBe(1); + expect(blocked.stdout).toContain("dedicated handoff subagent"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(false); + + const task = readTask(taskDir); + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + `\n`, + ); const result = runTask(root, "finish"); expect(result.status).toBe(0); - expect(result.stdout).toContain("Handoff updated"); - expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(true); + expect(result.stdout).not.toContain("Handoff updated"); + }); + + it("writes revision-bound handoffs from validated task results only", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + fs.mkdirSync(path.join(taskDir, "hermes", "dispatches"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "owned.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "src", "forged.ts"), "export {};\n"); + fs.writeFileSync(path.join(root, "unrelated.txt"), "other task change\n"); + const task = readTask(taskDir); + writeTask(taskDir, { ...task, confirmed_dispatches: ["job-1"] }); + const confirmedResult = { + job_id: "job-1", + task_revision: task.hermes_revision, + status: "success", + confirmed: true, + changed_files: ["src/owned.ts"], + }; + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "job-1.dispatch.json"), + `${JSON.stringify({ + job_id: "job-1", + hermes_revision: task.hermes_revision, + confirmed_revision: task.hermes_revision, + result_sha256: resultSha256(confirmedResult), + status: "confirmed", + })}\n`, + ); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "job-1.result.json"), + `${JSON.stringify({ ...confirmedResult, changed_files: ["src/forged.ts"] })}\n`, + ); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatches", "forged.result.json"), + `${JSON.stringify({ + job_id: "forged", + status: "success", + confirmed: true, + changed_files: ["src/forged.ts"], + })}\n`, + ); + + expect(run(root, "handoff").status).toBe(0); + const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); + expect(handoff).toContain( + ``, + ); + expect(handoff).not.toContain("- src/owned.ts"); + expect(handoff).not.toContain("src/forged.ts"); + expect(handoff).not.toContain("unrelated.txt"); + expect(handoff).toContain("job-1: confirmed result integrity check failed"); + }); + + it("refuses to replace a handoff symlink", () => { + if (process.platform === "win32") return; + expect(plan(root, ["Result A"]).status).toBe(0); + const sentinel = path.join(root, "sentinel.md"); + fs.writeFileSync(sentinel, "do not overwrite\n"); + fs.symlinkSync(sentinel, path.join(taskDir, "HANDOFF.md")); + + const result = run(root, "handoff"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("crosses a symlink"); + expect(fs.readFileSync(sentinel, "utf-8")).toBe("do not overwrite\n"); + }); + + it("derives next action from current closure state instead of stale task text", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const task = readTask(taskDir); + writeTask(taskDir, { ...task, next_action: "obsolete action" }); + const result = run(root, "next"); + expect(result.status).toBe(0); + expect(result.stdout).toContain("Run closure.py validate."); + expect(result.stdout).not.toContain("obsolete action"); + + const status = run(root, "status", "--json"); + expect(status.status).toBe(0); + expect(JSON.parse(status.stdout).next_action).toContain("Run closure.py validate."); }); it("prints a compact actionable audit gap", () => { diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 50002eb..fb4c761 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -208,7 +208,10 @@ function commitRepoState(repoRoot: string, message = "init"): void { } } -function runSessionStart(worktreeRoot: string): string { +function runSessionStart( + worktreeRoot: string, + hookInput: Record = {}, +): string { const sessionStart = getSharedHookScripts().find( (h) => h.name === "session-start.py", ); @@ -228,7 +231,7 @@ function runSessionStart(worktreeRoot: string): string { ...process.env, CLAUDE_PROJECT_DIR: worktreeRoot, }, - input: JSON.stringify({ cwd: worktreeRoot }), + input: JSON.stringify({ cwd: worktreeRoot, ...hookInput }), }); if (result.status !== 0) { throw new Error(result.stderr || result.stdout || "session-start failed"); @@ -239,7 +242,11 @@ function runSessionStart(worktreeRoot: string): string { } } -function runWorkflowState(repoRoot: string, contextId: string): string { +function runWorkflowState( + repoRoot: string, + contextId: string, + hookInput: Record = {}, +): string { const hook = getSharedHookScripts().find( (item) => item.name === "inject-workflow-state.py", ); @@ -258,7 +265,7 @@ function runWorkflowState(repoRoot: string, contextId: string): string { CLAUDE_PROJECT_DIR: repoRoot, TRELLIS_CONTEXT_ID: contextId, }, - input: JSON.stringify({ cwd: repoRoot, session_id: contextId }), + input: JSON.stringify({ cwd: repoRoot, session_id: contextId, ...hookInput }), }); if (result.status !== 0) { throw new Error(result.stderr || result.stdout || "workflow-state failed"); @@ -561,6 +568,54 @@ describe("shared-hooks capability table", () => { } }); + it("reinjects a compact closure capsule after a session compaction", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-compact-capsule-")); + try { + setupMainRepo(repoRoot, "compact-task", "# prd\n"); + const taskDir = path.join(repoRoot, ".trellis", "tasks", "compact-task"); + const taskJsonPath = path.join(taskDir, "task.json"); + const task = JSON.parse(fs.readFileSync(taskJsonPath, "utf-8")); + Object.assign(task, { + closure_state: "open", + closure_mode: "lean", + hermes_phase: "running", + intent: "Restore closure context after compaction", + definition_of_done: ["Focused validation is recorded"], + current_work_package: "WP1", + next_action: "stale persisted text", + blockers: [], + repair_count: 0, + max_repair_count: 1, + work_packages: [{ + id: "WP1", + title: "Restore state", + outcome: "Current work package remains visible", + done_when: ["Focused validation is recorded"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }], + }); + fs.writeFileSync(taskJsonPath, `${JSON.stringify(task, null, 2)}\n`); + const contextId = setSessionActiveTask(repoRoot, "compact-task", "compact-session"); + const payload = JSON.parse(runSessionStart(repoRoot, { + session_id: contextId, + source: "compact", + })) as { hookSpecificOutput?: { additionalContext?: string } }; + const context = payload.hookSpecificOutput?.additionalContext ?? ""; + expect(context).toContain("Status: HERMES CLOSURE"); + expect(context).toContain("Current: WP1"); + expect(context).toContain("Done when: Focused validation is recorded"); + expect(context).toContain("Complete WP1 outcome"); + expect(context).not.toContain("stale persisted text"); + expect(context.match(/Current: WP1/g)).toHaveLength(1); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("gives Codex the same project context index through its first prompt hook", () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-project-index-")); try { @@ -654,19 +709,52 @@ describe("shared-hooks capability table", () => { expect(context).toContain("Hermes closure task: capsule-task"); expect(context).toContain("Route: delivery"); expect(context).toContain("Route rule:"); + expect(context).toContain(""); + expect(context).toContain( + ".trellis/tasks/capsule-task/task.json", + ); + expect(context).toContain("No current HANDOFF.md exists"); expect(context).not.toContain("Claude review gates are read-only gates"); expect(context).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); const capsule = /\n([\s\S]*?)\n<\/task-capsule>/.exec(context)?.[1] ?? ""; expect(capsule.length).toBeLessThanOrEqual(1000); + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + const repeated = JSON.parse(runWorkflowState(repoRoot, contextId)) as { hookSpecificOutput: { additionalContext: string }; }; expect(repeated.hookSpecificOutput.additionalContext).toContain( - "anchor revision", + "Hermes closure update", + ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "", + ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "", + ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + ".trellis/tasks/capsule-task/HANDOFF.md", ); expect(repeated.hookSpecificOutput.additionalContext).not.toContain( + "HANDOFF_BODY_MUST_NOT_BE_INJECTED", + ); + + const stableHandoff = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(stableHandoff.hookSpecificOutput.additionalContext).not.toContain( + "/HANDOFF.md", + ); + + const compacted = JSON.parse(runWorkflowState(repoRoot, contextId, { + source: "compact", + })) as { hookSpecificOutput: { additionalContext: string } }; + expect(compacted.hookSpecificOutput.additionalContext).toContain( "", ); @@ -683,6 +771,24 @@ describe("shared-hooks capability table", () => { expect(revised.hookSpecificOutput.additionalContext).toContain( "", ); + expect(revised.hookSpecificOutput.additionalContext).toContain( + "HANDOFF.md is stale", + ); + + revisedTask.hermes_revision = 2; + revisedTask.current_work_package = + "PACKAGE_HEAD " + "x".repeat(2000) + " PACKAGE_TAIL"; + fs.writeFileSync(taskJsonPath, `${JSON.stringify(revisedTask, null, 2)}\n`); + const longAction = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + const resume = + /\n([\s\S]*?)\n<\/task-resume>/.exec( + longAction.hookSpecificOutput.additionalContext, + )?.[1] ?? ""; + expect(resume).toContain("PACKAGE_HEAD"); + expect(resume).not.toContain("PACKAGE_TAIL"); + expect(resume.length).toBeLessThanOrEqual(800); } finally { fs.rmSync(repoRoot, { recursive: true, force: true }); } @@ -1938,6 +2044,9 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expectMainAgentBashDenied( "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo; touch src/app.ts", ); + expectMainAgentBashDenied( + "python3 ./.trellis/scripts/closure.py handoff --task demo", + ); }); it("allows main-agent git status short paths as read-only Bash", () => { @@ -3059,6 +3168,39 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); + it("denies PreToolUse link creation for a dispatched coder", () => { + writeHermesWorkerRecords([ + taskCard("job-active", "coder"), + heartbeat("job-active"), + ]); + + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "coder", + tool_name: "Bash", + tool_input: { + job_id: "job-active", + command: "ln -s /tmp/escape .trellis/tasks/06-04-hermes-runtime/HANDOFF.md", + }, + session_id: "test-session", + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + hookSpecificOutput?: { + hookEventName?: string; + permissionDecision?: string; + permissionDecisionReason?: string; + }; + }; + expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); + expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); + expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( + "link creation is not allowed", + ); + }); + it("denies PreToolUse Bash scripts with unparsed destructive file APIs", () => { writeHermesWorkerRecords([ taskCard("job-active", "coder"), diff --git a/packages/cli/test/templates/trellis.test.ts b/packages/cli/test/templates/trellis.test.ts index 5f47d7f..bc4147f 100644 --- a/packages/cli/test/templates/trellis.test.ts +++ b/packages/cli/test/templates/trellis.test.ts @@ -599,6 +599,14 @@ describe("trellis template constants", () => { expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( "Main Agent / Main Pilot", ); + expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( + "Routing Priority", + ); + expect(templates.get("HERMES_MAIN_AGENT_BOOT_GUARD.md")).toContain( + "Do not wait for the user to invoke a command", + ); + expect(workflowMdTemplate).toContain("用户入口优先级"); + expect(workflowMdTemplate).toContain("主代理也必须在相应条件出现时自动触发"); expect(templates.get("metrics/metrics_schema.yaml")).toContain("HumanGate"); expect(templates.get("reports/report.md")).toContain("Core Conclusions"); expect(templates.get("schemas/result-envelope.schema.json")).toContain( From d44c26b26364aef0b79548c0e357df82151db6c3 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Wed, 22 Jul 2026 21:46:09 +0800 Subject: [PATCH 05/13] feat(hermes): streamline research collaboration workflow --- README.md | 2 +- docs/hermes_deployment_preflight.md | 47 +- docs/hermes_role_profile_migration.md | 2 +- ...es_trellis_research_transformation_plan.md | 2 +- docs/manual/06-tasks-and-planning.md | 13 + docs/manual/09-experiments.md | 35 +- docs/manual/13-workers-and-gates.md | 32 +- docs/manual/14-workflows-and-platforms.md | 10 +- docs/manual/17-lean-research-closure.md | 4 +- docs/manual/appendix-a-cli-reference.md | 8 +- .../appendix-c-configuration-reference.md | 2 +- docs/manual/appendix-e-troubleshooting.md | 2 +- marketplace/workflows/native/workflow.md | 31 +- marketplace/workflows/tdd/workflow.md | 4 +- packages/cli/scripts/hermes-preflight.js | 82 +- packages/cli/src/commands/init.ts | 4 +- packages/cli/src/commands/update.ts | 12 +- packages/cli/src/configurators/claude.ts | 18 +- packages/cli/src/configurators/codex.ts | 5 +- packages/cli/src/configurators/index.ts | 3 +- packages/cli/src/configurators/shared.ts | 8 +- .../templates/codex/agents/hermes-coder.toml | 5 +- .../codex/agents/hermes-planner.toml | 5 +- .../codex/agents/hermes-researcher.toml | 5 +- .../codex/agents/hermes-reviewer.toml | 5 +- .../templates/codex/agents/hermes-runner.toml | 5 +- .../templates/codex/agents/trellis-check.toml | 4 +- .../codex/agents/trellis-implement.toml | 4 +- .../codex/agents/trellis-research.toml | 4 +- packages/cli/src/templates/codex/config.toml | 14 +- .../templates/codex/hooks/session-start.py | 2 +- .../bundled-skills/hermes-research/SKILL.md | 10 +- .../customize-local/change-workflow.md | 2 +- .../platform-files/skills-and-commands.md | 2 +- .../src/templates/common/commands/continue.md | 4 +- .../templates/common/commands/finish-work.md | 25 +- .../src/templates/common/commands/handoff.md | 5 +- .../src/templates/common/commands/start.md | 17 +- .../src/templates/common/skills/before-dev.md | 10 +- .../src/templates/common/skills/brainstorm.md | 16 +- .../src/templates/common/skills/break-loop.md | 32 +- .../cli/src/templates/common/skills/check.md | 9 + .../src/templates/common/skills/grill-me.md | 14 +- .../skills/improve-codebase-architecture.md | 7 + .../cli/src/templates/common/skills/tdd.md | 6 + .../templates/common/skills/update-spec.md | 6 + packages/cli/src/templates/markdown/agents.md | 8 + .../shared-hooks/hermes-runtime-guard.py | 708 +++-------------- .../shared-hooks/inject-workflow-state.py | 42 +- .../templates/shared-hooks/session-start.py | 2 +- .../cli/src/templates/trellis/config.yaml | 8 +- .../hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md | 74 +- .../src/templates/trellis/hermes/config.yaml | 13 +- .../hermes/experiments/experiment.yaml | 13 +- .../trellis/hermes/records/recordbus.md | 21 +- .../hermes/records/subagent_protocol.md | 2 +- .../src/templates/trellis/scripts/closure.py | 10 +- .../trellis/scripts/common/closure.py | 173 ++++- .../trellis/scripts/common/dispatch.py | 330 -------- .../trellis/scripts/common/firewall.py | 21 +- .../templates/trellis/scripts/common/types.py | 1 + .../trellis/scripts/hermes/dispatch.py | 20 +- .../trellis/scripts/hermes/runner.py | 202 +---- .../trellis/scripts/hermes/runtime.py | 186 +++-- .../cli/src/templates/trellis/scripts/task.py | 30 +- .../cli/src/templates/trellis/workflow.md | 31 +- ...lank-bootstrap-closure.integration.test.ts | 35 +- .../test/commands/init.integration.test.ts | 18 +- .../commands/uninstall.integration.test.ts | 11 + .../test/commands/update.integration.test.ts | 12 + packages/cli/test/configurators/index.test.ts | 7 + .../cli/test/configurators/shared.test.ts | 32 +- packages/cli/test/regression.test.ts | 10 +- ...agent-context-firewall.integration.test.ts | 220 +----- .../test/scripts/closure.integration.test.ts | 219 ++++-- .../cli/test/scripts/hermes-preflight.test.ts | 91 +-- packages/cli/test/templates/codex.test.ts | 30 +- .../cli/test/templates/hermes-runtime.test.ts | 716 ++++++------------ .../cli/test/templates/shared-hooks.test.ts | 522 ++----------- packages/cli/test/templates/trellis.test.ts | 25 +- packages/core/src/task/schema.ts | 5 + packages/core/test/task/schema.test.ts | 2 + 82 files changed, 1537 insertions(+), 2857 deletions(-) diff --git a/README.md b/README.md index 22bee76..51104a3 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/docs/hermes_deployment_preflight.md b/docs/hermes_deployment_preflight.md index e483dba..cf64929 100644 --- a/docs/hermes_deployment_preflight.md +++ b/docs/hermes_deployment_preflight.md @@ -1,48 +1,25 @@ -# Hermes Deployment Candidate Hardening Preflight +# Hermes 本地工作流预检 -这版是 `deployment candidate hardening`(部署候选加固),不是完整 -`production ready`(生产就绪)平台。 +Hermes 面向真实科研项目工作区,不提供容器、沙箱或隔离运行环境。它使用任务状态、最小派发、运行记录和关闭审计协助科研迭代。 -## 紧急部署步骤 - -1. 先运行 quick gate: +## 发布前检查 ```bash pnpm --filter research-trellis hermes:preflight -- --quick -``` - -2. 发布前补跑完整验证: - -```bash pnpm --filter research-trellis exec vitest run test/scripts/agent-context-firewall.integration.test.ts test/scripts/closure.integration.test.ts test/templates/hermes-runtime.test.ts test/templates/hermes-roles.test.ts test/templates/trellis.test.ts test/templates/claude.test.ts test/templates/codex.test.ts test/templates/shared-hooks.test.ts -PYTHONPYCACHEPREFIX=/tmp/research-trellis-pycache python3 -m py_compile packages/cli/src/templates/trellis/scripts/common/*.py packages/cli/src/templates/trellis/scripts/hermes/*.py packages/cli/src/templates/shared-hooks/*.py pnpm --filter research-trellis typecheck pnpm --filter research-trellis build ``` -3. 若 Python 编译后出现 `__pycache__` 或 `.pyc` 在模板目录内,不能发布;用 `gio trash` 或 `trash` 清理后重跑 preflight。 - -## 安全 Gate - -- 正式上下文防火墙只支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。Claude 的 `Agent`(代理)工具必须启用项目钩子;Codex 原生代理仅是建议性入口,需要强制控制时必须使用 `dispatch.py run --platform codex --mode strict`(Codex 严格派发)。 -- `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会覆盖心跳判断。`lean`(轻量)模式会显示强警告;`standard`(标准)模式要求新鲜钩子或严格执行心跳;`publication`(发表)模式缺少该硬门禁时必须拒绝派发和关闭。 -- Claude Agent 调用只传 `job_id`(工作编号),不允许异步调用。Codex 严格包装使用 `codex exec --output-schema --json -o`(结构化执行)并把原始跟踪保存在 `.trellis/.runtime/hermes-traces/`(本地跟踪目录);发布包和聊天只使用校验后的净化结果。 -- `Bash` 写入守卫是最佳努力,不是完整 shell 沙箱。hook 会解析常见重定向和 `tee` 写入;遇到写入语义但无法明确解析目标时必须 `fail closed`。 -- `runner.py` 必须按任务 `experiment.yaml` 的 `allowed_commands` 执行命令白名单;未匹配命令直接失败。`allowed_commands` is not a strong command sandbox(不是强命令沙箱);如果允许 `python3`,仍能执行任意 Python 行为。需要更强控制时,应使用更严格 exact match(精确匹配)或外部 OS 级隔离。 -- `sandbox.required=true` 且 `mode=none` 必须拒绝运行;`mode=none` 只是本地执行,不是隔离环境。`container` 当前只提供 `Docker`(容器运行时)薄切片:通过 `docker run --rm`(一次性容器执行命令)挂载仓库,并在容器工作目录执行已通过白名单的命令;这仍是 `not an OS sandbox`(不是操作系统沙箱),也不是 `production ready`(生产就绪)的强隔离。`external` 当前仍只做可用性检查,不能声称已经提供 `OS sandbox`(操作系统沙箱)。 -- `runner.py --cwd`、`--input`、`--output` 必须留在仓库内;`report.py --output` 必须留在当前任务 Hermes 目录内。 -- runner 默认使用最小环境变量白名单,`run_manifest` 不记录完整环境,避免 secret env 泄露。 -- `approval_records` 必须依赖 `external human/root approval`(外部人工/根权限批准)流程写入。当前实现只能校验字段、claim/evidence 关联和决策值;它不是 OS 级不可写权限隔离。 -- `JSONL` 记录是 append-only(追加写)约定,不是 `not tamper-proof`(不可篡改)存储。需要防篡改审计时,应使用外部日志、权限或签名机制。 - -## 失败处理 +## 当前约定 -- 缺关键模板文件:补齐模板后重跑 `hermes:preflight`。 -- Bash gate 失败:改用受 Hermes runner 管理的命令,或把写入文件纳入任务卡 `allowed_files`。 -- 命令不在 `allowed_commands`:更新任务级 `experiment.yaml`,不要绕过 runner。 -- approval 失败:由外部 human/root 流程补写完整 approval 记录,agent 不能自行生成批准。 -- 防火墙状态异常:运行 `python3 ./.trellis/scripts/hermes/dispatch.py status --task `(派发状态),确认平台、环境变量和最新心跳;不能用手工改写心跳文件绕过 `standard`(标准)或 `publication`(发表)门禁。 +- `runner.py`(运行器)直接在当前项目环境执行。`allowed_commands`(允许命令)用于记录实验可复现的命令边界,不是安全隔离。 +- `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)都使用紧凑派发和结果摘要;完整日志、差异和搜索过程保留在文件中。 +- `task.json`(任务状态)是事实源。`HANDOFF.md`(交接摘要)只用于暂停、阶段切换和恢复上下文,不阻止结束任务或关闭任务。 +- `close`(关闭)仍需满足工作包、证据和审计条件;人工科研决策仍由项目负责人确认。 -## 已知边界 +## 常见问题 -这版是部署候选加固薄切片:它阻断已知高风险绕过,并为 `container`(容器)模式提供可验证的 `Docker`(容器运行时)最小执行路径,但不提供用户权限、文件系统 `ACL`(访问控制列表)或系统调用级隔离;也就是 `not an OS sandbox`(不是操作系统沙箱),更不是生产级强隔离。需要强隔离时,应在后续版本把 `Hermes runner`(Hermes 运行器)放入独立受限执行环境。 +- 命令不在 `allowed_commands`(允许命令)中:更新当前任务的 `experiment.yaml`(实验配置),并保持命令尽可能具体。 +- 任务暂停:生成 `HANDOFF.md`(交接摘要);恢复时先读取 `task.json`(任务状态),再按需读取交接摘要。 +- 模板目录出现 `__pycache__` 或 `.pyc`:使用 `trash`(回收站命令)清理后重跑预检。 diff --git a/docs/hermes_role_profile_migration.md b/docs/hermes_role_profile_migration.md index 46a606c..8011409 100644 --- a/docs/hermes_role_profile_migration.md +++ b/docs/hermes_role_profile_migration.md @@ -45,7 +45,7 @@ | 两平台正式 Hermes 模板合计 | 7 个 / 9873 字符 | 10 个 / 8904 字符 | 新增 Codex 支持后仍减少 9.8% | | 单个正式模板平均长度 | 1410 字符 | 890 字符 | 减少 36.9% | -派发正文测试限制在 2000 字符内。`Claude Code`(Claude 代码工具)只向 `Agent`(代理)传 `job_id`(工作编号),`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)由统一 CLI 包装器执行。 +派发正文测试限制在 2000 字符内。`Claude Code`(Claude 代码工具)只向 `Agent`(代理)传 `job_id`(工作编号),`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,直接在当前项目工作区协作。 ## 兼容边界 diff --git a/docs/hermes_trellis_research_transformation_plan.md b/docs/hermes_trellis_research_transformation_plan.md index f275eed..60ae88a 100644 --- a/docs/hermes_trellis_research_transformation_plan.md +++ b/docs/hermes_trellis_research_transformation_plan.md @@ -1,6 +1,6 @@ # Hermes on Trellis 科研化改造总计划 -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development`(子代理驱动开发) or `superpowers:executing-plans`(执行计划) to implement this plan task-by-task. Steps should be tracked with checkbox syntax in later implementation issues. +> **状态:历史设计记录。** 本文保留早期讨论,不是当前运行规范;其中关于容器、沙箱、严格隔离和额外安全门禁的内容已不再采用。当前行为以中文手册、任务胶囊、Lean Closure 和本地工作流预检为准。 **Goal:** 把 Trellis 从任务协作脚手架,改造成可审计、可复现、可评估的科研实验框架。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index 65dcbd1..c26a9ff 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -16,6 +16,10 @@ ## 操作步骤 +每个新的可执行任务都先由主代理分析 intent(意图)、scope(范围)、definition of done(完成定义)、科研协议影响、风险和最小工作包拆分。已有任务的后续请求先分成三类:范围内续接继续当前工作包;低风险计划变化使用 `amend`(修改计划);新假设、模型功能架构、数据、指标、基线或主张范围变化进入 exploration(探索)并重新校验。范围内补充不能重新生成整套工作包。 + +协作判断固定为三档:普通可逆任务直接执行,不增加讨论或记录步骤;存在有限不确定性时,明确关键假设后继续;只有高风险研究变化才暂停讨论并由用户批准科研取舍。判断以事实和证据为先,应保留不确定性和有依据的异议。证据冲突或关键假设失效时,把任务标记为 `blocked`(阻塞),再通过批准的 `amend`(修改计划)更新研究协议。提示词不能替代结构化记录,也不能让每个任务都进入 `grill`(方案讨论)。 + 1. 创建任务: ```bash @@ -77,6 +81,15 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ 数据集、研究假设、数据切分、指标定义、基线、主张范围和任务范围属于高风险字段,必须增加 `--approved-by human/root`(人工批准)。变更直接写入 `task.json`(任务状态),同时向 `task-events.jsonl`(任务事件)追加 `plan_amended`(计划已修改)记录;变更后重新运行 closure 校验。正式研究仍可额外使用 Hermes `plan_change`(研究计划变更记录)保存更完整的提案和审阅链。 +新完成的高风险 exploration(探索)讨论还需要引用已有决策记录: + +```bash +python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ + --decision-ref design.md +``` + +引用必须位于仓库或当前任务内。记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 + ## 预期结果 任务创建时状态为 `planning`(规划中);启动后变为 `in_progress`(进行中);只有 close gate(关闭门禁)通过后才变为 `completed`(已完成),随后可移动到 `.trellis/tasks/archive//`(月度归档目录)。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 5c940a0..d4a5015 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -2,12 +2,14 @@ ## 目标 -为每个科研任务固定问题、假设、数据、被测对象、指标、环境、允许命令和产物目录。 +为每个科研任务固定问题、假设、数据、被测对象、指标、环境、允许命令和产物目录。运行器直接在当前项目环境执行命令,不提供容器或沙箱运行模式。 ## 适用范围 适用于本地脚本、测试、基准、数据处理和模型评估。纯文档任务仍会生成实验骨架,但启动前也必须把占位字段改成非空内容。 +数据预检不是通用步骤。只有任务属于 exploration(探索),且 `research_change_fields`(研究变化字段)包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时才启用;普通任务、冻结协议执行和不涉及这三个字段的探索任务都不增加预检。 + ## 前置条件 - 已创建任务并设置 `TASK`(任务目录名)变量。 @@ -39,21 +41,23 @@ environment: shell: "bash" allowed_commands: - "python3 scripts/evaluate.py" -sandbox: - mode: "none" - required: false artifact_dir: ".trellis/tasks/任务名/hermes/runs" ``` -3. 尽量把 `allowed_commands`(允许命令)写成完整命令前缀,而不是只写 `python3`(Python 3 命令)。当前匹配支持可执行文件名、完整命令或命令前缀,但它不是强安全边界。 -4. 选择运行隔离方式: +3. 对需要数据预检的任务,增加 `data_preflight`(数据预检): + +```yaml +data_preflight: + source: "公开评估集" + version: "2026-07" + input_manifest: "data/eval-manifest.json" + hash: "sha256:实际文件的64位小写哈希" + checks_ref: "data/eval-checks.yaml" +``` -| 模式 | 当前状态 | -| --- | --- | -| `none`(无隔离) | 已实现且为默认值;直接在本机运行 | -| `container`(容器) | 实验性;需要 `docker`(容器命令)和有效镜像,运行器会挂载仓库 | -| `external`(外部) | 配置可解析,但当前运行器不提供外部沙箱执行,实际运行会失败 | +`input_manifest`(输入清单)与 `data_path`(数据路径)二选一,`hash`(哈希值)必须与所指文件一致。`checks_ref`(检查记录)必须位于仓库内,并把 `schema`(结构)、`missing`(缺失值)、`duplicates`(重复项)和 `split_leakage`(切分泄漏)分别记为 `checked`(已检查)或 `not_applicable`(不适用)。运行时还要用 `runner.py run --input`(声明运行输入)同时传入数据文件和检查记录,二者会带哈希写入运行清单。 +4. 尽量把 `allowed_commands`(允许命令)写成完整命令前缀,而不是只写 `python3`(Python 3 命令)。它用于实验复现和任务边界,不是安全隔离。 5. 校验配置: ```bash @@ -67,18 +71,17 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 失败恢复 - 字段为空或类型错误:按报错修正;指标和允许命令必须是非空列表,随机种子必须是整数。 -- `sandbox.required=true`(必须隔离)却使用 `mode: none`(无隔离):改用可用容器,或停止运行。 -- 容器命令不可用:安装并验证 `docker`(容器命令),或在明确接受风险后改回无隔离模式。 - 命令被拒绝:不要扩大到笼统的可执行文件;把确实需要的完整命令前缀加入配置并重新审阅。 +- 数据预检失败:修正仓库内路径、实际文件哈希或四项检查状态。校验通过前运行器不会执行命令,也不会写入成功运行状态。 +- 旧配置中的 `sandbox`(沙箱)字段可以保留,但运行器会忽略它并直接在项目环境执行。 ## 验证记录 - 日期:2026-07-15。 - 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 -- 命令:`rg -n -m 1 "allowed_commands|sandbox" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 -- 结果:命令允许列表、隔离模式和对应测试均可定位。 -- 未验证项:本轮未启动真实容器或外部隔离环境。 +- 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 +- 结果:命令允许列表和本地运行记录均可定位。 ## 来源 diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 4f875e2..ce24167 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -16,6 +16,14 @@ ## 操作步骤 +### 科研协作触发 + +主代理以事实和证据为先,不以同意用户为目标;应明确重要假设、不确定性和有依据的异议,科研取舍仍由用户批准。普通可逆任务直接执行,有限不确定性声明假设后执行,只有高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后继续。 + +高风险 exploration(探索)完成 `grill-me`(方案讨论)后,必须用 `decision_ref`(决策记录引用)指向已有 `prd.md`(需求文档)或 `design.md`(设计文档)。记录包含决定、理由、证据、备选方案和失效条件,派发事件与 Task Capsule(任务胶囊)只传引用。提示词不能替代该记录;旧完成标记没有引用时仅警告,普通任务不新增讨论门禁。 + +只有 exploration 且研究变化包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时,运行代理才检查任务级 `data_preflight`(数据预检)。其他任务的角色派发、运行和复核步骤保持不变。 + ### 五个角色 `Role`(角色)决定权限、工具和责任边界。`Profile`(模式)决定这个角色本次具体做什么。模式不是新的子代理,也没有单独的平台代理模板。 @@ -36,6 +44,24 @@ - `runner`(运行代理):`experiment`(实验)、`test`(测试)、`build`(构建)、`validation`(验证)。 - `reviewer`(复核代理):`quality`(质量)、`evidence`(证据)、`claim`(主张)、`safety`(安全)、`closure`(关闭)、`statistics`(统计)。 +### 科研技能路由 + +技能由主代理按实际需要触发,不设每轮固定数量,也不要求组成完整流水线。多个技能确有依赖时按顺序使用,前一步结果通过文件引用复用。 + +| 场景 | 主代理动作 | 执行角色 | +| --- | --- | --- | +| 新任务 | 分析意图、范围、完成定义、科研分流和 1–4 个结果型工作包 | 复杂设计可由 `planner`(规划代理)提供候选 | +| 需求不清楚 | 按需触发 `brainstorm`(问题梳理) | 主代理与用户讨论,`researcher`(检索代理)补资料 | +| 关键科研决策 | 按需触发 `grill-me`(方案讨论) | 主代理与用户讨论,`planner` 比较方案但不批准 | +| 代码工作包 | 触发 `before-dev`(开发前准备),任务明确选择时才使用 TDD | `coder`(编码代理)实现,`runner`(运行代理)执行测试 | +| 工程验收 | 触发代码检查 | `runner` 执行,`reviewer:quality`(质量复核)独立判断 | +| 正式实验 | 触发 `hermes-research`(科研记录) | `runner` 登记运行,证据或统计由对应 `reviewer` 复核 | +| 重复技术失败 | 触发 `break-loop`(失败归因) | `planner:root_cause`(根因分析)判断,`coder` 执行限定修复 | +| 交接或收尾 | 请求交接、关闭审查和最终验证 | `coder:configuration` 写交接,`reviewer:closure` 审计,`runner` 验证和归档 | +| 稳定知识 | 关闭前评估一次 `update-spec`(更新规范) | `coder:configuration` 写入,`reviewer` 核对证据 | + +纯实验、文献和证据任务不触发代码技能。代码测试通过不等于科研证据充分;正常的负实验结果也不进入技术调试循环。 + 三个最小示例: ```yaml @@ -72,7 +98,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id " ```bash python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" \ - --job-id "$JOB" --platform codex --mode strict + --job-id "$JOB" --platform codex ``` 4. 返回必须是单个 `Result Envelope`(结果信封)JSON,包含 `uncertainties`(不确定项),其中 `conclusion`(结论)最多 1200 字符。非法 JSON、长日志、完整差异、搜索过程、敏感信息和越界改动会被拒绝;raw trace(原始跟踪)只保存在 `.trellis/.runtime/hermes-traces/`(本地运行目录)。 @@ -102,7 +128,7 @@ python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" 7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。当前内置完整运行时门禁只在 `Claude Code`(Claude 代码工具)和已启用钩子的 `Codex`(代码代理)配置中注册。 -当前 `Stop`(结束)完成检查偏向代码任务:它要求编码结果覆盖当前非 `.trellis/`(工作流目录)差异,并有相关的成功运行清单和质量复核记录。纯研究任务也可能触发这个要求,这是测试版的已知限制。 +`Stop`(结束)按实际工作类型检查:存在实现代码改动时要求编码结果、成功运行清单和独立质量复核;没有代码改动的实验、检索或规划工作要求对应角色结果和独立复核,正式运行还必须引用成功的运行清单。它不会为了纯研究任务强制创建编码结果。 ### 证据整理工具 @@ -144,7 +170,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" - 钩子未触发:检查平台配置;环境变量 `TRELLIS_HOOKS=0`(关闭钩子)或 `TRELLIS_DISABLE_HOOKS=1`(禁用钩子)会直接关闭门禁。 - 角色或模式组合无效:只使用本页五个正式角色及其模式,不要把模式写成新角色。 - 旧角色出现弃用提示:按迁移表更新派发配置;历史 `JSONL`(逐行 JSON)记录不需要重写。 -- 子代理读取内容过多:确认 Claude 只传 `job_id`(工作编号),或 Codex 使用 strict(严格)包装器;引用不能超过 3 个。 +- 子代理读取内容过多:确认 Claude 只传 `job_id`(工作编号),Codex 使用紧凑派发;引用不能超过 3 个。 - 门禁与任务类型不匹配:保留失败记录并由人工决定调整流程,不要把门禁当作安全认证。 ## 验证记录 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 7c098ba..7130ea7 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -71,7 +71,7 @@ trust_level = "trusted" `v0.7.1-beta.0`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 -五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)依赖已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex native`(Codex 原生代理)仅为 `advisory`(建议性),`Codex strict`(Codex 严格模式)通过 `codex exec --output-schema --json -o`(结构化执行参数)执行。能力缺失时,`lean`(轻量)警告降级,`standard`(标准)必须有钩子或严格模式,`publication`(发表)拒绝派发和关闭。 +五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,直接在当前项目工作区协作。关闭条件仍由任务记录、证据和审计决定。 ## 用户入口 @@ -81,13 +81,13 @@ trust_level = "trusted" | --- | --- | --- | | `continue`(继续任务) | 恢复活动任务 | 读取紧凑任务状态并只推进下一动作 | | `status`(查看状态) | 查看阶段、工作包、阻塞和下一动作 | 只读,不会改变任务状态 | -| `handoff`(写入交接) | 在暂停、切换会话或压缩上下文前生成交接摘要 | 由受限子代理写入 `HANDOFF.md`(交接摘要),不会关闭任务 | +| `handoff`(写入交接) | 在暂停、切换会话或压缩上下文前生成交接摘要 | 推荐下发子代理写入 `HANDOFF.md`(交接摘要),不会关闭任务 | | `finish-work`(完成收尾) | 审计、关闭并归档已完成任务 | closure 任务必须先通过审计和关闭门禁 | | `start`(启动会话) | 兼容没有会话开始钩子的环境 | 支持钩子的主会话通常自动完成启动 | `Claude Code`(Claude 代码工具)和其他原生命令平台会把这些入口显示为平台的命令形式;`Codex`(代码代理平台)使用同名技能入口。命令前缀由平台决定,不应把某个平台的前缀复制到另一个平台。 -建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于用户明确要求讨论研究设计、架构或范围,后者用于用户明确要求把已验证经验写入规范;主代理在相同条件出现时也应自动触发对应过程。为保持旧项目兼容和自动路由,平台仍会安装 `brainstorm`(需求讨论)、`before-dev`(开发前准备)、`check`(检查)、`tdd`(测试驱动)、`break-loop`(重复问题分析)和架构审查等内部技能;它们不是日常需要记忆的入口,也不会替代 closure 的状态、审计和关闭门禁。 +建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。其他技能按科研阶段和实际工作触发,不设每轮固定数量:需求确有缺口才使用 `brainstorm`(问题梳理);代码工作包才使用 `before-dev`(开发前准备)和 `check`(代码检查);`tdd`(测试驱动)必须由用户或任务策略明确选择;`break-loop`(失败归因)只处理重复技术失败;软件架构技能不能代替科研模型设计;正式实验和证据记录使用 `hermes-research`(科研记录)。技能不替代 closure 的状态、审计和关闭门禁。 6. 逐平台能力和限制如下。“子代理”表示仓库会安装对应代理模板;“门禁”专指 `hermes-runtime-guard.py`(Hermes 运行时门禁),不包括只做上下文注入的同名钩子事件。 @@ -96,7 +96,7 @@ trust_level = "trusted" | `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 正式 Hermes Agent 只传 `job_id`(工作编号),钩子替换提示并净化结果 | 完整 | 需平台读取项目设置;异步 Hermes Agent 被拒绝;环境开关可关闭 | | `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)注入 capsule 和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 没有每轮 capsule;只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | | `OpenCode`(开放代码工具) | `chat.message`(会话消息)注入会话摘要、每轮状态和 capsule | 支持;代理按派发首行自行读取 | 无 | 依赖 OpenCode 插件被实际加载;会话检索功能仍未实现 | -| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | native(原生)仅建议;strict(严格)由统一派发 CLI 执行 | 写入和结束门禁;严格模式另有结果门禁 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅);发表模式必须使用硬门禁 | +| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 使用紧凑派发与结果文件,直接在当前工作区执行 | 任务状态、证据和关闭审计 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅) | | `Kilo CLI`(Kilo 命令行) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | | `Kiro Code`(Kiro 代码工具) | 无通用主会话注入 | 支持;`agentSpawn`(代理创建)时推送 | 无 | 只注册代理创建钩子,无会话开始和每轮主会话钩子 | | `Gemini CLI`(Gemini 命令行) | `SessionStart + BeforeAgent`(会话开始和代理前) | 支持;代理前置说明拉取 | 无 | 依赖 `.gemini/settings.json`(Gemini 设置)的当前钩子协议 | @@ -125,7 +125,7 @@ trust_level = "trusted" - 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 -- 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并区分 Codex native 与 strict。 +- 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 - 未验证项:本轮未启动 14 种平台主机,也未访问远程工作流市场。 ## 来源 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index fbeeb26..8a6ac15 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -153,11 +153,11 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## Handoff -`handoff`(交接)入口会创建一个受限的 `coder:configuration`(配置修改)子代理派发。主代理不直接运行写入命令;子代理只能生成当前任务的 `HANDOFF.md`,其结果确认后由确定性代码写入最终版本。 +`handoff`(交接)通常下发给 `coder:configuration`(配置修改)子代理生成当前任务的 `HANDOFF.md`(交接摘要)。它也可由 closure 命令直接生成紧凑状态摘要。 `HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 -有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。`HANDOFF.md`(交接摘要)会写入对应的任务修订号;只有修订号匹配时才提示读取一次,旧修订的交接会明确标记为过期而不会被使用。接收到 `compact`(上下文压缩)或 `clear`(清理上下文)事件时,会强制重注入任务胶囊;交接刷新也会触发一次重注入。交接文件全文不会自动注入,也不会默认传给子代理。 +有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。暂停后恢复、切换代理或压缩上下文时,提示会给出 `HANDOFF.md`(交接摘要)路径;任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理。 上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 3649040..e006a01 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -45,7 +45,7 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit | --- | --- | --- | | `continue`(继续) | 恢复活动任务 | 无直接写入 | | `status`(状态) | 显示阶段、工作包、阻塞和下一动作 | 只读 | -| `handoff`(交接) | 派发受限子代理写入 `HANDOFF.md`(交接摘要) | 只更新交接文件 | +| `handoff`(交接) | 生成或下发子代理编写 `HANDOFF.md`(交接摘要) | 只更新交接文件 | | `finish-work`(收尾) | 审计、关闭并归档满足条件的任务 | 可能归档 | | `start`(启动) | 兼容无自动启动钩子的环境 | 无直接写入 | @@ -67,7 +67,7 @@ python3 ./.trellis/scripts/closure.py package-block --task "$TASK" --reason "阻 python3 ./.trellis/scripts/closure.py amend --task "$TASK" --field --value --reason python3 ./.trellis/scripts/closure.py audit --task "$TASK" python3 ./.trellis/scripts/closure.py repair --task "$TASK" -python3 ./.trellis/scripts/closure.py handoff --task "$TASK" # 仅交接子代理执行 +python3 ./.trellis/scripts/closure.py handoff --task "$TASK" python3 ./.trellis/scripts/closure.py close --task "$TASK" ``` @@ -94,13 +94,13 @@ Agent Context Firewall(代理上下文防火墙)统一命令: python3 ./.trellis/scripts/hermes/dispatch.py create --task "$TASK" --role --profile --objective python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id python3 ./.trellis/scripts/hermes/dispatch.py show --task "$TASK" --job-id --prompt -python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" --job-id --platform codex python3 ./.trellis/scripts/hermes/dispatch.py apply --task "$TASK" --job-id --result result.json python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id ``` -`create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)的 `Codex native`(Codex 原生)为建议性,`strict`(严格)才执行结构化包装;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 +`create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)在当前项目工作区使用紧凑派发协议;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index 261f943..30458b8 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -25,7 +25,7 @@ | `packages`(包列表) | 多包项目路径和类型 | | `codex.dispatch_mode`(Codex 调度模式) | 旧 Trellis 实现/检查代理的路由设置;不会把 Hermes 原生派发升级为强门禁 | -`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令、审批边界和 `context_firewall`(上下文防火墙)。正式平台只有 `claude`(Claude)与 `codex`(Codex):Claude 依赖钩子强制替换,`Codex native`(Codex 原生派发)仅提供建议,`Codex strict`(Codex 严格模式)通过统一派发命令执行。 +`.trellis/hermes/config.yaml`(科研全局配置)定义记录位置、任务卡规则、运行命令、审批边界和 `context_firewall`(上下文防火墙)。正式平台只有 `claude`(Claude)与 `codex`(Codex):Claude 可依赖钩子替换,Codex 使用同一套紧凑派发和结果文件。 防火墙相关环境变量: diff --git a/docs/manual/appendix-e-troubleshooting.md b/docs/manual/appendix-e-troubleshooting.md index 75e278d..5bda22c 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -36,7 +36,7 @@ | 派发提示 `stale_dispatch`(过期派发) | 当前 `hermes_revision`(Hermes 修订号)已变化,重新创建派发 | | 返回提示 `invalid_json`(非法 JSON)或 `long_log`(长日志) | 只返回结果信封 JSON;日志、差异和搜索过程保留在文件或本地原始跟踪中 | | 两次返回无效后 job 被阻塞 | 修正返回契约并创建替代派发;不要把整个科研任务改成完成 | -| `standard`(标准)或 `publication`(发表)提示防火墙不可用 | 启用并批准 Claude/Codex 钩子,或使用 `Codex strict`(Codex 严格模式);发表模式不能降级到原生建议模式 | +| 子代理派发没有收到最小上下文 | 检查 Claude/Codex 钩子是否启用,或显式读取当前派发文件;关闭仍以任务记录、证据和审计为准 | | `close`(关闭)提示结果未确认 | 先处理所有派发结果和工作包,再显式运行 `audit`(审计)与 `close`(关闭) | | 质量门禁缺少统计字段 | 见[指标与比较](11-metrics-and-comparison.md)中的当前限制 | | 审批门禁失败 | 等待真实 `human/root`(人工根权限)批准记录 | diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 8ba9f09..2582655 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -89,13 +89,15 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 +科研协作以证据和事实为先,不以迎合用户结论为目标。主代理应明确重要假设、不确定性和有证据的异议,科研取舍由用户批准。普通可逆任务直接执行;有限不确定性先声明假设再执行;只有改变研究问题、协议或主张边界的高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后再继续。提示词不能代替任务和实验中的结构化记录,也不能因此让每个任务都经过 grill。 + ```bash # 规划与验证 python3 ./.trellis/scripts/closure.py plan --task \ --intent "可观察目标" --done-when "完成条件" # 研究协议分流:探索任务需记录变化字段和已完成 grill python3 ./.trellis/scripts/closure.py route --task --route exploration --research-change hypothesis -python3 ./.trellis/scripts/closure.py grill --task --complete +python3 ./.trellis/scripts/closure.py grill --task --complete --decision-ref design.md python3 ./.trellis/scripts/closure.py validate --task # 每次只处理当前 package @@ -108,10 +110,10 @@ python3 ./.trellis/scripts/closure.py audit --task python3 ./.trellis/scripts/closure.py repair --task python3 ./.trellis/scripts/closure.py close --task -# 正式角色派发:Claude 只传 job_id;Codex strict 由 CLI 执行 +# 正式角色派发:Claude 只传 job_id;Codex 使用同一紧凑派发协议 python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective "任务" python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id -python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex ``` 每轮 hook 优先注入约 500–1000 字符的 Task Capsule,只包含 intent、范围、当前 package、done_when、next action、blockers 和最多 3 个相关引用。完整 PRD、报告、事件、ledger、历史任务和非当前 spec 均保留,但只在需要时读取。Claude Code、Codex 与 OpenCode 使用相同 capsule 语义;没有每轮 hook 的平台需要显式运行 `closure.py capsule`。 @@ -122,7 +124,9 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - 科研任务按研究协议是否改变分流,不按任务大小。`delivery`(确定性交付)不改变研究协议,等价重构和性能优化留在这里;`execution`(已冻结协议的实验执行)只运行种子或预先批准的参数搜索;`exploration`(探索性研究)记录 hypothesis、model_architecture、dataset、split、preprocessing、objective/loss、metric_definition、baseline、claim_scope 中实际变化的字段,并在 validate 前记录已完成的 grill。新假设或模型功能架构变化属于高风险探索,必须创建独立探索任务(可挂同一父项目),不能伪装成普通 work package。 -Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex native 仅为 advisory,strict 才机械执行和净化结果。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 +新完成的高风险 grill 必须用 `decision_ref`(决策记录引用)指向仓库内或任务内的 `prd.md`(需求文档)、`design.md`(设计文档)等记录。该记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件);事件和 Task Capsule 只保存引用,不注入正文。旧任务只有 `grill_completed=true`(已完成讨论)而没有引用时继续兼容并提示警告。涉及 dataset、split 或 preprocessing 的 exploration 还必须在任务级 `experiment.yaml`(实验配置)填写 `data_preflight`(数据预检);其他 exploration、delivery 和 execution 不增加该步骤。 + +Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 @@ -213,9 +217,10 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 [workflow-state:no_task] -No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +No active task. First analyze every actionable request: intent, scope, observable completion, research-protocol impact, risk, and the smallest useful 1-4 package split. Then ask for task-creation consent before creating any Trellis task. Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +Do not load a planning skill by default. Use brainstorm only for unresolved requirements and grill-me only for a material unresolved research or scope decision. 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] @@ -226,6 +231,8 @@ Complex task: ask the user if you can create a Trellis task and enter the planni 建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 +技能不按固定数量加载,也不构成一条每次都要执行的流水线。新任务由主代理完成轻量分析和 1–4 个结果型工作包;已有任务的普通补充继续当前工作包,只有范围或研究协议变化才 amend 并重新 validate。主代理负责触发和整合,具体执行按权限下发:`before-dev` 给 coder,测试/实验给 runner,质量/证据/统计/closure 给 reviewer,根因或方法分析给 planner,资料检索给 researcher,规范与 handoff 写入给 `coder:configuration`。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) @@ -244,7 +251,8 @@ Multi-deliverable scope: consider a parent task plus independently verifiable ch Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. -For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +Use three intervention levels: execute ordinary reversible work directly; state material assumptions and proceed under limited uncertainty; pause only for a high-risk research change, then record the approved decision. Evidence conflict or a failed critical assumption means blocked plus an approved amendment. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. A new completed high-risk exploration grill also records a repository- or task-relative decision_ref; prompts never replace this record, and ordinary tasks do not acquire a grill gate. When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the same task-level review-gate set. @@ -258,7 +266,7 @@ Before `task.py start`, record the development strategy decisions in the task do [workflow-state:planning-inline] Hermes closure: use the injected Task Capsule first. Run `closure.py plan` and `closure.py validate`; do not treat commands or file operations as packages. -Load `trellis-brainstorm`; stay in planning. +Analyze every new task, but load `trellis-brainstorm` only for unresolved requirements and `trellis-grill-me` only for a material unresolved research decision. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. @@ -280,14 +288,15 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, or blocking. At closure, reviewer checks closure and runner performs final validation/archive actions. +Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. -Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. +Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. Dispatch prompt starts with `Active task: `. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. Claude review gates are read-only gates. They report PASS / FAIL, blocking issues, and suggested next actions; they do not modify code directly. -If a Claude review gate fails, the main agent repairs the code, then re-runs the same gate before advancing. +If a Claude code review gate fails, the main agent dispatches a bounded coder repair, then re-runs the same gate before advancing. For any enabled `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, and `trellis-merge-review`, count repeated failures per gate and per task. If the same gate blocks the same task more than 3 times in a row, the main agent must briefly report that status to the user, re-check whether the requirements have drifted, and ask whether to skip the current review gate. If the chosen strategy is `subagent + worktree`, all code-development subagents must use the same `./.trellis/trellis-worktrees/` path. If the chosen strategy is TDD, align implementation and review expectations to `trellis-tdd`. diff --git a/marketplace/workflows/tdd/workflow.md b/marketplace/workflows/tdd/workflow.md index 7a1086c..b6ae1cd 100644 --- a/marketplace/workflows/tdd/workflow.md +++ b/marketplace/workflows/tdd/workflow.md @@ -190,7 +190,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni [workflow-state:planning] -Load `trellis-brainstorm`; stay in planning. +Analyze the task first; load `trellis-brainstorm` only if requirements remain unresolved. TDD is already an explicit task strategy, not evidence for a scientific claim. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. TDD planning gate: record observable behavior slices, the public interface under test, and mock boundaries before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. @@ -204,7 +204,7 @@ Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research mani into a sub-agent. --> [workflow-state:planning-inline] -Load `trellis-brainstorm`; stay in planning. +Analyze the task first; load `trellis-brainstorm` only if requirements remain unresolved. TDD is already an explicit task strategy, not evidence for a scientific claim. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. TDD planning gate: record observable behavior slices, the public interface under test, and mock boundaries before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. diff --git a/packages/cli/scripts/hermes-preflight.js b/packages/cli/scripts/hermes-preflight.js index 2ead1fc..b1feb23 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -142,40 +142,13 @@ export function checkHookMatcherIncludes(repoRoot = DEFAULT_REPO_ROOT) { messages.push(`hook matcher missing ${matcher}`); } } - if (!content.includes("cannot safely parse Bash")) { - messages.push("Bash write parsing must fail closed when targets are unclear"); + if (!content.includes("BASH_EXECUTION_ROLES")) { + messages.push("Bash execution role boundary is missing"); } return result("Hermes hook matcher", messages.length === 0, messages); } -export function checkSecurityGateDocumentation(repoRoot = DEFAULT_REPO_ROOT) { - const docPath = path.join(repoRoot, "docs/hermes_deployment_preflight.md"); - if (!fs.existsSync(docPath)) { - return result("Hermes security gate docs", false, [ - "missing docs/hermes_deployment_preflight.md", - ]); - } - const content = fs.readFileSync(docPath, "utf-8"); - const required = [ - "deployment candidate hardening", - "Bash", - "fail closed", - "allowed_commands", - "not a strong command sandbox", - "approval_records", - "human/root", - "external human/root approval", - "JSONL", - "not tamper-proof", - "not an OS sandbox", - ]; - const messages = required - .filter((text) => !content.includes(text)) - .map((text) => `deployment docs must mention: ${text}`); - return result("Hermes security gate docs", messages.length === 0, messages); -} - -export function checkSandboxConfiguration(repoRoot = DEFAULT_REPO_ROOT) { +export function checkLocalRunnerConfiguration(repoRoot = DEFAULT_REPO_ROOT) { const configPath = path.join( repoRoot, "packages/cli/src/templates/trellis/hermes/config.yaml", @@ -184,39 +157,26 @@ export function checkSandboxConfiguration(repoRoot = DEFAULT_REPO_ROOT) { repoRoot, "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", ); - const docPath = path.join(repoRoot, "docs/hermes_deployment_preflight.md"); const messages = []; - for (const requiredPath of [configPath, experimentPath, docPath]) { + for (const requiredPath of [configPath, experimentPath]) { if (!fs.existsSync(requiredPath)) { - messages.push(`missing sandbox boundary file: ${rel(repoRoot, requiredPath)}`); + messages.push(`missing local runner file: ${rel(repoRoot, requiredPath)}`); } } const config = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : ""; const experiment = fs.existsSync(experimentPath) ? fs.readFileSync(experimentPath, "utf-8") : ""; - const docs = fs.existsSync(docPath) ? fs.readFileSync(docPath, "utf-8") : ""; - for (const [name, content] of [ - ["config.yaml", config], - ["experiment.yaml", experiment], - ]) { - for (const text of ["sandbox:", "mode:", "required:"]) { - if (!content.includes(text)) { - messages.push(`${name} must include sandbox ${text}`); - } - } + if (!config.includes("runs directly in the project workspace")) { + messages.push("config.yaml must describe direct project-workspace execution"); } - for (const text of [ - "sandbox.required=true", - "mode=none", - "container", - "external", - "not an OS sandbox", - "not a strong command sandbox", - ]) { - if (!docs.includes(text)) { - messages.push(`deployment docs must mention sandbox boundary: ${text}`); + for (const text of ["allowed_commands:", "artifact_dir:"]) { + if (!experiment.includes(text)) { + messages.push(`experiment.yaml must include ${text}`); } } - return result("Hermes sandbox configuration", messages.length === 0, messages); + if (config.includes("sandbox:") || experiment.includes("sandbox:")) { + messages.push("local runner templates must not configure a sandbox"); + } + return result("Hermes local runner configuration", messages.length === 0, messages); } export function checkPythonCompile(repoRoot = DEFAULT_REPO_ROOT) { @@ -303,7 +263,7 @@ function usage() { return [ "Usage: node packages/cli/scripts/hermes-preflight.js [--quick] [--skip-python-compile]", "", - "--quick skips template vitest, typecheck, and build, but keeps security/file/cache checks.", + "--quick skips template vitest, typecheck, and build, but keeps template/file/cache checks.", ].join("\n"); } @@ -314,8 +274,7 @@ export function runPreflight(options = {}) { checkHermesRequiredFiles(repoRoot), checkNoPythonCaches(repoRoot), checkHookMatcherIncludes(repoRoot), - checkSecurityGateDocumentation(repoRoot), - checkSandboxConfiguration(repoRoot), + checkLocalRunnerConfiguration(repoRoot), ]; if (options.skipPythonCompile) { @@ -374,16 +333,13 @@ async function main() { } console.log( - `Hermes deployment preflight${args.quick ? " (quick mode)" : ""}`, - ); - console.log( - "Scope: deployment candidate hardening, not production ready.", + `Hermes local workflow preflight${args.quick ? " (quick mode)" : ""}`, ); console.log( - "Security gate: Bash write parsing is best-effort and fails closed; allowed_commands is not a strong command sandbox.", + "Scope: lightweight local research collaboration.", ); console.log( - "Approval and storage: approval_records require external human/root approval; JSONL is not tamper-proof storage. This is not an OS sandbox.", + "Runner: commands execute in the current project workspace; allowed_commands records the experiment contract.", ); const checks = runPreflight(args); for (const check of checks) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 883be3a..f8f3f7d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -638,7 +638,7 @@ function getBlankProjectBootstrapPrd(pythonCmd: string): string { ## 工作方式 -- 这是一次需要方案讨论的启动任务。先完成聚焦讨论,再记录 \`closure.py grill --complete\`。 +- 这是一次需要方案讨论的启动任务。先完成聚焦讨论,把 Decision、Rationale、Evidence、Alternatives 和 Failure Conditions 五项写入本任务的 \`prd.md\`,再记录 \`closure.py grill --complete --decision-ref prd.md\`。 - 讨论完成后,创建一个新的普通任务;它应有明确的 \`intent\`(目标)、\`definition_of_done\`(完成定义)和 1–4 个以结果为单位的工作包。 - 首个任务应验证一个最小结果,例如固定样例上的可重复输出、基线比较或数据读取链路;不要把“搭完整平台”作为首个任务。 - 启动任务只负责建立项目合同和首个任务,不负责实现该任务。 @@ -653,7 +653,7 @@ function getBlankProjectBootstrapPrd(pythonCmd: string): string { ## 完成方式 \`\`\`bash -${pythonCmd} ./.trellis/scripts/closure.py grill --task 00-bootstrap-guidelines --complete +${pythonCmd} ./.trellis/scripts/closure.py grill --task 00-bootstrap-guidelines --complete --decision-ref prd.md ${pythonCmd} ./.trellis/scripts/closure.py validate --task 00-bootstrap-guidelines ${pythonCmd} ./.trellis/scripts/closure.py package-start --task 00-bootstrap-guidelines --package-id WP1 ${pythonCmd} ./.trellis/scripts/closure.py package-check --task 00-bootstrap-guidelines --package-id WP1 diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index f836b96..2e265b5 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -687,6 +687,16 @@ function collectTemplateFiles( } } + // A pre-existing root CLAUDE.md has no Trellis write receipt. Keep it + // outside update ownership even under --force. + const rootClaudePath = "CLAUDE.md"; + if ( + fs.existsSync(path.join(cwd, rootClaudePath)) && + !loadHashes(cwd)[rootClaudePath] + ) { + files.delete(rootClaudePath); + } + preserveExistingClaudeStatusLine(cwd, files); // Apply update.skip from config.yaml (unless bypassed for breaking release) @@ -1005,7 +1015,7 @@ function backupFile( const BACKUP_DIRS = ALL_MANAGED_DIRS; /** Root-level managed files to include in update backups. */ -const BACKUP_FILES = [FILE_NAMES.AGENTS] as const; +const BACKUP_FILES = [FILE_NAMES.AGENTS, "CLAUDE.md"] as const; /** * Patterns to exclude from backup (user data that shouldn't be backed up) diff --git a/packages/cli/src/configurators/claude.ts b/packages/cli/src/configurators/claude.ts index 0dcbf0a..195f0b1 100644 --- a/packages/cli/src/configurators/claude.ts +++ b/packages/cli/src/configurators/claude.ts @@ -1,9 +1,7 @@ -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { AI_TOOLS } from "../types/ai-tools.js"; -import { - getAllCommands as getClaudeCommands, -} from "../templates/claude/index.js"; +import { getAllCommands as getClaudeCommands } from "../templates/claude/index.js"; import { getClaudeTemplatePath } from "../templates/extract.js"; import { ensureDir, writeFile } from "../utils/file-writer.js"; import { @@ -16,6 +14,11 @@ import { replacePythonCommandLiterals, } from "./shared.js"; +export const CLAUDE_THIN_ENTRY = `# Claude Code + +Use \`AGENTS.md\` as the project instructions. Read \`.trellis/project/PROJECT_INDEX.md\`, then the current Task Capsule or \`HANDOFF.md\` only when needed. Do not duplicate those rules here. +`; + const EXCLUDE_PATTERNS = [ ".d.ts", ".d.ts.map", @@ -103,4 +106,11 @@ export async function configureClaude(cwd: string): Promise { resolveSkills(ctx), resolveBundledSkills(ctx), ); + + // Root CLAUDE.md is a Claude-only discovery pointer. Never claim or replace + // a project-owned file, including during force initialization. + const rootEntry = path.join(cwd, "CLAUDE.md"); + if (!existsSync(rootEntry)) { + await writeFile(rootEntry, CLAUDE_THIN_ENTRY); + } } diff --git a/packages/cli/src/configurators/codex.ts b/packages/cli/src/configurators/codex.ts index 86a37d3..e5d941e 100644 --- a/packages/cli/src/configurators/codex.ts +++ b/packages/cli/src/configurators/codex.ts @@ -27,9 +27,8 @@ import { */ export async function configureCodex(cwd: string): Promise { // Shared skills from common source → .agents/skills/ - // Uses the neutral placeholder resolver so the 5 shared workflow skills - // (brainstorm, before-dev, check, break-loop, update-spec) render to the - // same bytes regardless of which platform writes them — required because + // Uses the neutral placeholder resolver so shared workflow skills render to + // the same bytes regardless of which platform writes them — required because // Gemini CLI 0.40+ also targets `.agents/skills/` (last-writer-wins is // safe when both writers produce identical output). const sharedSkillsRoot = path.join(cwd, ".agents", "skills"); diff --git a/packages/cli/src/configurators/index.ts b/packages/cli/src/configurators/index.ts index b1d400a..63fa2ad 100644 --- a/packages/cli/src/configurators/index.ts +++ b/packages/cli/src/configurators/index.ts @@ -18,7 +18,7 @@ import { } from "../types/ai-tools.js"; // Platform configurators -import { configureClaude } from "./claude.js"; +import { CLAUDE_THIN_ENTRY, configureClaude } from "./claude.js"; import { configureCursor } from "./cursor.js"; import { configureOpenCode, collectOpenCodeTemplates } from "./opencode.js"; import { configureCodex } from "./codex.js"; @@ -182,6 +182,7 @@ const PLATFORM_FUNCTIONS: Record = { `.claude/${settings.targetPath}`, resolvePlaceholders(settings.content), ); + files.set("CLAUDE.md", CLAUDE_THIN_ENTRY); return files; }, }, diff --git a/packages/cli/src/configurators/shared.ts b/packages/cli/src/configurators/shared.ts index fb65b25..06c2361 100644 --- a/packages/cli/src/configurators/shared.ts +++ b/packages/cli/src/configurators/shared.ts @@ -162,7 +162,7 @@ export function resolvePlaceholders( * Identical to {@link resolvePlaceholders} except that {@link CMD_REF} is * rendered in a platform-neutral form (`` `name` (Trellis command) ``) * instead of substituting a platform-specific prefix. This is the only - * placeholder that varies between platforms in the 5 shared workflow skills + * placeholder that varies between platforms in the shared workflow skills * (`brainstorm`, `before-dev`, `check`, `break-loop`, `update-spec`), so * neutralizing it makes the rendered SKILL.md files byte-identical regardless * of which Trellis configurator wrote them — eliminating the @@ -196,7 +196,7 @@ export function resolvePlaceholdersNeutral( result = result.replace(RE_USER_ACTION_LABEL, context.userActionLabel); result = result.replace(RE_CLI_FLAG, context.cliFlag); - // Conditional blocks (resolved per platform — none of the 5 shared skills + // Conditional blocks (resolved per platform — none of the shared skills // use conditionals, but Codex-only command-as-skill files might in future). const flagValues: Record<(typeof CONDITIONAL_FLAGS)[number], boolean> = { AGENT_CAPABLE: context.agentCapable, @@ -433,7 +433,7 @@ export function resolveCommands(ctx: TemplateContext): ResolvedTemplate[] { } /** - * Resolve only the 5 skill templates with trellis- prefix + SKILL.md frontmatter. + * Resolve shared workflow skill templates with trellis- prefix + SKILL.md frontmatter. * Used by "both" platforms for the auto-triggered skills. */ export function resolveSkills(ctx: TemplateContext): ResolvedTemplate[] { @@ -471,7 +471,7 @@ export function resolveSkillsNeutral(ctx: TemplateContext): ResolvedTemplate[] { /** * Same as {@link resolveAllAsSkills} but uses - * {@link resolvePlaceholdersNeutral} for the 5 shared skills. The 2 command + * {@link resolvePlaceholdersNeutral} for the shared skills. The command * templates (continue, finish-work) folded into the skill set still resolve * `{{CLI_FLAG}}` / `{{PYTHON_CMD}}` per platform — only Codex writes those * files into `.agents/skills/`, so byte-identity isn't required there. diff --git a/packages/cli/src/templates/codex/agents/hermes-coder.toml b/packages/cli/src/templates/codex/agents/hermes-coder.toml index ecd9aa2..715f777 100644 --- a/packages/cli/src/templates/codex/agents/hermes-coder.toml +++ b/packages/cli/src/templates/codex/agents/hermes-coder.toml @@ -1,11 +1,12 @@ name = "hermes-coder" description = "Hermes coder for bounded implementation, tests, configuration, and repair." -sandbox_mode = "workspace-write" +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are the Hermes coder. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: coder:`, current work package, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: coder:`, current work package, objective, and at most three refs. Modify only `allowed_files`, avoid `forbidden_files`, and keep one active writer per worktree. Return exactly the Result Envelope JSON; never include logs, diffs, or search history. Do not approve evidence or claims, close the task, or widen scope without an amended dispatch. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-planner.toml b/packages/cli/src/templates/codex/agents/hermes-planner.toml index b56e32d..97e9cdf 100644 --- a/packages/cli/src/templates/codex/agents/hermes-planner.toml +++ b/packages/cli/src/templates/codex/agents/hermes-planner.toml @@ -1,11 +1,12 @@ name = "hermes-planner" description = "Hermes planner for research design, bounded task planning, root-cause analysis, and method selection." -sandbox_mode = "read-only" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes planner. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: planner:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: planner:`, objective, and at most three refs. Follow the active Profile and return exactly the Result Envelope JSON with a bounded conclusion, uncertainties, blockers, and next action. Do not edit implementation code, mark packages done, approve high-risk changes or claims, or close the task. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-researcher.toml b/packages/cli/src/templates/codex/agents/hermes-researcher.toml index ce4007c..52b77b3 100644 --- a/packages/cli/src/templates/codex/agents/hermes-researcher.toml +++ b/packages/cli/src/templates/codex/agents/hermes-researcher.toml @@ -1,11 +1,12 @@ name = "hermes-researcher" description = "Hermes researcher for literature, codebase, official documentation, and prior-art searches." -sandbox_mode = "workspace-write" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes researcher. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: researcher:`, objective, and at most three refs. For enforced execution, the main agent must use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: researcher:`, objective, and at most three refs. Read only sources needed by the active Profile. Return exactly the Result Envelope JSON with traceable refs, applicability limits, and uncertainties. Do not modify code, results, claims, approvals, package state, or closure state. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-reviewer.toml b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml index ea24928..1251090 100644 --- a/packages/cli/src/templates/codex/agents/hermes-reviewer.toml +++ b/packages/cli/src/templates/codex/agents/hermes-reviewer.toml @@ -1,11 +1,12 @@ name = "hermes-reviewer" description = "Hermes independent reviewer for quality, evidence, claims, safety, closure, and statistics." -sandbox_mode = "read-only" +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Hermes reviewer. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated blind-review dispatch body with `job_id`, bound revision, `role: reviewer:`, objective, and at most three refs. Do not read coder/runner explanations. For enforced execution, use `dispatch.py run --platform codex --mode strict`. +Require one validated blind-review dispatch body with `job_id`, bound revision, `role: reviewer:`, objective, and at most three refs. Do not read coder/runner explanations. Read only current artifacts, ledgers, criteria, and cited refs required by the active Profile. Return exactly the Result Envelope JSON. Evidence/claim profiles may propose a judgment but cannot approve facts, claims, or human records. """ diff --git a/packages/cli/src/templates/codex/agents/hermes-runner.toml b/packages/cli/src/templates/codex/agents/hermes-runner.toml index 0710c4a..94dd116 100644 --- a/packages/cli/src/templates/codex/agents/hermes-runner.toml +++ b/packages/cli/src/templates/codex/agents/hermes-runner.toml @@ -1,11 +1,12 @@ name = "hermes-runner" description = "Hermes runner for experiments, tests, builds, and validation with reproducible records." -sandbox_mode = "workspace-write" +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are the Hermes runner. Do not spawn another sub-agent. -Native Codex dispatch is advisory. Require one validated Hermes dispatch body with `job_id`, bound revision, `role: runner:`, current work package, objective, and at most three refs. For enforced execution, use `dispatch.py run --platform codex --mode strict`. +Require one validated Hermes dispatch body with `job_id`, bound revision, `role: runner:`, current work package, objective, and at most three refs. Run only authorized commands and register exact commands in run manifests, not in chat. Return exactly the Result Envelope JSON with existing `run_refs`; `evidence_refs` must be empty because runner success is not accepted evidence. """ diff --git a/packages/cli/src/templates/codex/agents/trellis-check.toml b/packages/cli/src/templates/codex/agents/trellis-check.toml index 34f4b49..c0b4623 100644 --- a/packages/cli/src/templates/codex/agents/trellis-check.toml +++ b/packages/cli/src/templates/codex/agents/trellis-check.toml @@ -1,7 +1,7 @@ name = "trellis-check" description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix. diff --git a/packages/cli/src/templates/codex/agents/trellis-implement.toml b/packages/cli/src/templates/codex/agents/trellis-implement.toml index 83cc3b7..560209a 100644 --- a/packages/cli/src/templates/codex/agents/trellis-implement.toml +++ b/packages/cli/src/templates/codex/agents/trellis-implement.toml @@ -1,7 +1,7 @@ name = "trellis-implement" description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-terra" +model_reasoning_effort = "max" developer_instructions = """ You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work. diff --git a/packages/cli/src/templates/codex/agents/trellis-research.toml b/packages/cli/src/templates/codex/agents/trellis-research.toml index 1e0efeb..4ec97ec 100644 --- a/packages/cli/src/templates/codex/agents/trellis-research.toml +++ b/packages/cli/src/templates/codex/agents/trellis-research.toml @@ -1,7 +1,7 @@ name = "trellis-research" description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere." -sandbox_mode = "workspace-write" - +model = "gpt-5.6-sol" +model_reasoning_effort = "high" developer_instructions = """ You are the Trellis researcher agent. diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index e878787..ba7b36c 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -8,6 +8,10 @@ # # Without trust, the [features] block below is loaded but disabled. +# Default coordinator model for this research workflow. +model = "gpt-5.6-sol" +model_reasoning_effort = "high" + # Keep AGENTS.md as the primary project instruction file. project_doc_fallback_filenames = ["AGENTS.md"] @@ -33,11 +37,7 @@ enabled = true max_concurrent_threads_per_session = 3 min_wait_timeout_ms = 120000 -# Native custom-agent dispatch cannot mechanically replace or validate the -# agent prompt/output, so its Context Firewall authority is advisory only. -# Use the generated dispatch CLI strict wrapper for enforced Hermes work: -# python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +# Native custom-agent dispatch uses the validated Hermes dispatch and Result +# Envelope files. It works directly in the current project workspace. [hermes.context_firewall] -native_authority = "advisory" -strict_authority = "enforced" -strict_runtime = "codex exec --output-schema --json -o" +native_authority = "protocol" diff --git a/packages/cli/src/templates/codex/hooks/session-start.py b/packages/cli/src/templates/codex/hooks/session-start.py index 24a721a..2e7ae2d 100644 --- a/packages/cli/src/templates/codex/hooks/session-start.py +++ b/packages/cli/src/templates/codex/hooks/session-start.py @@ -157,7 +157,7 @@ def _build_hermes_main_agent_boot_guard(trellis_dir: Path) -> str: Source: .trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md. Read it before governance-sensitive work; keep this SessionStart payload compact. Role: Main Agent / Main Pilot coordinates Hermes state, bounded subagent routing, record validation, handoff, and human/PI stop points. Hard limits: do not directly modify source, metrics, dataset splits, baselines, official evaluation, claim_allowed=true, or main merge unless Hermes state and human/root authority allow it. -Formal Codex context policy: native is advisory; use validated_dispatch_only through dispatch.py strict mode for enforced work. Keep raw output out of chat. +Formal Codex context policy: use validated_dispatch_only with the current task dispatch. Keep raw output out of chat. Record policy: chat is not completion; completed, failed, blocked, stale, interrupted, or capacity-blocked work needs structured records. Claim boundary: keep engineering success, runner success, evidence review, proxy evidence, scientific evidence, claim approval, and merge approval separate. """ diff --git a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md index edb6dbf..331d0dc 100644 --- a/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md +++ b/packages/cli/src/templates/common/bundled-skills/hermes-research/SKILL.md @@ -7,11 +7,17 @@ description: "Use for Hermes research tasks that need append-only evidence, clai Use this skill when a task asks for Hermes research discipline, evidence ledgers, claim review, human approval records, or state-machine-aware research progress. +The main agent triggers it for formal experiments and structured research +records. `runner` owns commands, manifests, artifacts, and raw observations; +`reviewer:evidence` and `reviewer:statistics` judge sufficiency and quality; +`reviewer:claim` proposes claim disposition. Command success and a single run +never become accepted evidence or a durable claim by themselves. + ## Read First 1. Resolve the active Hermes task. 2. Create a validated dispatch bound to the current `hermes_revision`. -3. Pass only its `job_id` to Claude Agent, or use Codex strict `dispatch.py run`. +3. Pass only its `job_id` to Claude Agent; Codex uses the same compact dispatch protocol. 4. Read at most three direct references named by the canonical dispatch. 5. Accept only the validated Result Envelope; raw traces never enter chat. @@ -64,7 +70,7 @@ Runtime helpers are available under `.trellis/scripts/hermes/`: - `python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective ` - `python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id ` - `python3 ./.trellis/scripts/hermes/dispatch.py show --task --job-id --prompt` -- `python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict` +- `python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex` - `python3 ./.trellis/scripts/hermes/dispatch.py apply --task --job-id --result ` - `python3 ./.trellis/scripts/hermes/dispatch.py list --task ` - `python3 ./.trellis/scripts/hermes/record.py append --task --record-type worker --json ''` diff --git a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md index aa2e663..8483b85 100644 --- a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +++ b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md @@ -49,7 +49,7 @@ If the user wants only one platform to avoid sub-agents, first confirm whether t | `status` | Artifact state | Resume at | | --- | --- | --- | -| `planning` | `prd.md` missing | Phase 1.1 (load `trellis-brainstorm`) | +| `planning` | `prd.md` missing | Phase 1.1 (analyze first; load `trellis-brainstorm` only for unresolved requirements) | | `planning` | lightweight task with `prd.md` complete | ask for start review, then run `task.py start` | | `planning` | complex task missing `design.md` or `implement.md` | complete missing planning artifacts | | `planning` | complex task has `prd.md`, `design.md`, and `implement.md` | ask for start review, then run `task.py start` | diff --git a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md index 816c666..3590084 100644 --- a/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +++ b/packages/cli/src/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md @@ -11,7 +11,7 @@ Skills and commands are textual entry points for user interaction with Trellis. | prompt | Explicit user invocation or platform selection | Similar to command, but in a platform prompt format. | | workflow | Explicit user selection or platform auto-match | Guides the main session when no sub-agent/hook exists. | -Trellis workflow skills usually share one semantic set: brainstorm, before-dev, check, update-spec, break-loop. Multi-file built-in skills such as `trellis-meta` use layered references. +Trellis workflow skills share one semantic set: research framing (`brainstorm`, `grill-me`), conditional engineering (`before-dev`, `tdd`, `check`, architecture review), technical diagnosis (`break-loop`), and durable knowledge (`update-spec`). Multi-file built-in skills such as `hermes-research` and `trellis-meta` use layered references. ## Common Paths diff --git a/packages/cli/src/templates/common/commands/continue.md b/packages/cli/src/templates/common/commands/continue.md index 92e72bf..5e0c91b 100644 --- a/packages/cli/src/templates/common/commands/continue.md +++ b/packages/cli/src/templates/common/commands/continue.md @@ -30,9 +30,9 @@ Shows the Phase Index (Plan / Execute / Finish) with routing + skill mapping. ## Step 3: Decide Where You Are -`get_context.py` shows the active task's `status` field. Route by `status` + artifact presence. This command replaces the user needing to remember the Trellis flow; it does not itself approve implementation. +`get_context.py` shows the active task's `status` field. First classify the new request as an in-scope continuation, a bounded plan change, or a new task. In-scope detail stays in the current work package and does not regenerate the plan. Route by `status` + artifact presence; this command does not itself approve implementation. -- `status=planning` + no `prd.md` → **1.1** (load `trellis-brainstorm`) +- `status=planning` + no `prd.md` → **1.1** (analyze the task; load `trellis-brainstorm` only if requirements remain unresolved) - `status=planning` + `prd.md` only → decide whether the task is lightweight or complex. Lightweight can move to **1.4** review; complex returns to **1.1** to add `design.md` + `implement.md`. - `status=planning` + complex artifacts complete + sub-agent jsonl not curated (only the seed `_example` row) → **1.3** - `status=planning` + required artifacts complete + required jsonl curated or inline mode → **1.4** (ask for start review; only run `task.py start` after user confirms) diff --git a/packages/cli/src/templates/common/commands/finish-work.md b/packages/cli/src/templates/common/commands/finish-work.md index 1917d9e..9ef38a5 100644 --- a/packages/cli/src/templates/common/commands/finish-work.md +++ b/packages/cli/src/templates/common/commands/finish-work.md @@ -42,13 +42,15 @@ Then route: > "FYI, dirty files outside this task's scope — leaving them for the other window: ``." - **Genuinely unsure** — ask the user once: "Are `` this task's work I forgot to commit, or another window's? (commit / ignore)" — then route per their answer. -## Step 3: Run Hermes closure gates +## Step 3: Review Hermes closure gates -Use the current active task name for ``. Every closure task runs audit and close: +Use the current active task name for ``. The main agent coordinates this +step but does not self-approve closure. First run the deterministic audit, then +create a validated `reviewer:closure` dispatch against `task.json`, the audit +result, package dispositions, blockers, and evidence references: ```bash {{PYTHON_CMD}} ./.trellis/scripts/closure.py audit --task -{{PYTHON_CMD}} ./.trellis/scripts/closure.py close --task ``` For `standard` and `publication`, also validate the existing Hermes records: @@ -65,15 +67,26 @@ For `publication` only, additionally run the formal comparison gate: {{PYTHON_CMD}} ./.trellis/scripts/hermes/report.py quality-gate --task ``` -Run only the gates required by `closure_mode`. Do not archive while `closure_state` is not `closed`. Legacy tasks without closure fields retain the original compatibility path. +Run only the gates required by `closure_mode`. A reviewer records gaps but does +not modify task state. Do not advance while the independent closure verdict has +blocking gaps. Do not archive until that verdict and the deterministic gate both +pass. Legacy tasks without closure fields retain the original compatibility +path. + +## Step 4: Close and archive through a runner -## Step 4: Archive task(s) +After the reviewer passes closure, create a validated `runner:validation` +dispatch for the required final checks and these control commands: ```bash +{{PYTHON_CMD}} ./.trellis/scripts/closure.py close --task {{PYTHON_CMD}} ./.trellis/scripts/task.py archive ``` -At minimum: the current active task (if any). Plus any extra tasks the user confirmed in Step 1. Each archive produces a `chore(task): archive ...` commit via the script's auto-commit. +The runner reports command results and archive location; it does not reinterpret +evidence or approve claims. At minimum archive the current active task, plus any +extra tasks the user confirmed in Step 1. Each archive produces a +`chore(task): archive ...` commit via the script's auto-commit. If there is no active task and the user did not confirm any cleanup archives, skip this step. diff --git a/packages/cli/src/templates/common/commands/handoff.md b/packages/cli/src/templates/common/commands/handoff.md index cbe2b19..7c31c9c 100644 --- a/packages/cli/src/templates/common/commands/handoff.md +++ b/packages/cli/src/templates/common/commands/handoff.md @@ -20,9 +20,7 @@ The main agent must not run the write command. Create a validated ``` The worker receives the current `task.json` and runs -`closure.py handoff --task `. It returns a normal Result Envelope. After -that result is confirmed, the deterministic closure code rewrites the handoff -with the new task revision. +`closure.py handoff --task `. It returns a normal Result Envelope. Read the sanitized worker result, then report the generated `HANDOFF.md` path, current phase, current work package, blockers, and next action. Keep the chat @@ -33,6 +31,7 @@ For a legacy task without closure fields, do not invent a Hermes handoff. Report ## Boundaries - Writing a handoff does not complete, close, archive, approve, or amend the task. +- `task.json` remains the source of truth; a handoff is a recovery summary, not a gate. - Do not load full chat history or unrelated task files to create the handoff. - The dedicated handoff writer is the one exception: it may only write the current task's `HANDOFF.md` and must not change task state or other files. diff --git a/packages/cli/src/templates/common/commands/start.md b/packages/cli/src/templates/common/commands/start.md index 1d3f66b..24b9a7c 100644 --- a/packages/cli/src/templates/common/commands/start.md +++ b/packages/cli/src/templates/common/commands/start.md @@ -38,8 +38,8 @@ From Step 1 you know the current task and status. Check the task directory: For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task ` first. Use only the current package and its related references; load full PRD, reports, event history, ledgers, old tasks, and unrelated specs on demand. -- **Active task status `planning` + no `prd.md`** → Phase 1.1. Load the `trellis-brainstorm` skill. -- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. For a Lean Research Closure task, read the Task Capsule first, then use `closure.py plan` and `closure.py validate`; `trellis-grill-me` is optional and only needed for an unresolved decision. Lightweight tasks can otherwise be PRD-only. For legacy or explicitly complex Claude Code tasks, keep requirement clarification in `prd.md` and use `trellis-grill-me` as the required planning gate before development strategy decisions or `task.py start`. Complex tasks need `design.md` + `implement.md`. Before `task.py start`, record the decisions in the task documents: development mode, branch vs worktree, default flow vs TDD, plus the task-level review-gate selection. Ask these in one `A.` / `B.` / `C.` style strategy block. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Complex tasks should keep that record in `implement.md` together with the enabled/disabled review gates and the preserved order for any enabled Claude review gates: `trellis-spec-review` → `trellis-code-review` → `trellis-code-architecture-review`. While the choice is still open, `Optional review gates status: pending` is allowed; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists for the selectable review gates (`trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, `trellis-merge-review`). If the user leaves all optional gates off, still record all five in the disabled list while keeping `trellis-check` fixed outside that set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin `./.trellis/trellis-worktrees/`. On Claude Code, that shared worktree is different from host `Agent(..., isolation: "worktree")`; when both collide, the hook removes the host isolation input before dispatch. If the strategy is TDD, record `trellis-tdd` as the reference flow. Also record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the task-level review-gate set. +- **Active task status `planning` + no `prd.md`** → Phase 1.1. Analyze intent, scope, completion criteria, research route, and a 1-4 package candidate split. Load `trellis-brainstorm` only if requirements remain unresolved after reading relevant project evidence. +- **Active task status `planning` + `prd.md` exists** → stay in Phase 1. Read the Task Capsule, keep existing work packages for an in-scope continuation, and use `closure.py amend` for a real plan change. `trellis-grill-me` is optional and only applies to an unresolved material research or scope decision; task complexity alone never makes it mandatory. Lightweight tasks can be PRD-only, while code-heavy complex tasks add `design.md` and `implement.md` with their development strategy and explicitly selected review gates before `task.py start`. - **Active task status `in_progress`** → Phase 2 step 2.1. Load the step detail: ```bash {{PYTHON_CMD}} ./.trellis/scripts/get_context.py --mode phase --step 2.1 --platform {{CLI_FLAG}} @@ -47,7 +47,7 @@ For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py cap - **Hermes phase `review`** → run `closure.py audit`; use `closure.py repair` only for listed gaps and within the configured limit. - **Hermes phase `blocked`** → read the capsule and `HANDOFF.md`; do not widen scope or bypass human approval. - **Hermes phase `closed`** → the task may be finished or archived; this does not imply claim approval. -- **No active task** → classify first. For simple conversation / small task, ask only whether this turn should create a Trellis task. For complex work, ask whether you may create a Trellis task and enter planning. If the user says no, skip Trellis for this session. +- **No active task** → analyze every actionable request first, then ask whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. --- @@ -55,10 +55,11 @@ For a Hermes closure task, run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py cap | User intent | Skill | |---|---| -| New feature / unclear requirements | `trellis-brainstorm` | -| About to write code | `trellis-before-dev` | -| Done coding / quality check | `trellis-check` | -| Stuck / fixed same bug multiple times | `trellis-break-loop` | -| Learned something worth capturing | `trellis-update-spec` | +| Research question, scope, or done criteria remain unclear | `trellis-brainstorm` | +| Material research-contract decision remains unresolved | `trellis-grill-me` | +| Current work package requires code | coder uses `trellis-before-dev` | +| Code work is complete | runner verifies; reviewer uses `trellis-check` | +| Repeated technical failure, not a negative research result | planner uses `trellis-break-loop` | +| Reviewed durable knowledge exists near closure | coder configuration uses `trellis-update-spec` | Full rules + anti-rationalization table in `.trellis/workflow.md`. diff --git a/packages/cli/src/templates/common/skills/before-dev.md b/packages/cli/src/templates/common/skills/before-dev.md index f9d2a83..c5d7c12 100644 --- a/packages/cli/src/templates/common/skills/before-dev.md +++ b/packages/cli/src/templates/common/skills/before-dev.md @@ -1,4 +1,12 @@ -Read the relevant development guidelines before starting your task. +# Before Development + +Main triggers this skill only when the current work package requires code, +tests, or configuration changes. A validated `coder` dispatch uses it; pure +literature, experiment-only, evidence-review, and claim-review packages skip it. +It loads the smallest relevant engineering constraints and does not decide the +scientific hypothesis, model choice, metric, or claim. + +Read the relevant development guidelines before starting implementation. Execute these steps: diff --git a/packages/cli/src/templates/common/skills/brainstorm.md b/packages/cli/src/templates/common/skills/brainstorm.md index 7a49174..e8a988d 100644 --- a/packages/cli/src/templates/common/skills/brainstorm.md +++ b/packages/cli/src/templates/common/skills/brainstorm.md @@ -1,10 +1,16 @@ # Trellis Brainstorm -## Non-Negotiable Interview Contract +## Focused Research Framing Contract -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. +The main agent triggers this skill only when the research question, scope, or +observable completion criteria remain unclear after repository-first analysis. +It is not part of every task. Clear delivery and frozen-protocol execution tasks +go directly to a bounded closure plan. -Ask the questions one at a time. +Resolve only uncertainties that block planning. Ask one high-value question at +a time and include a recommended answer. A researcher may collect missing +sources; a planner may propose framing, but the main agent retains routing and +the user retains research decisions. ## Non-Negotiable Evidence Rule @@ -12,7 +18,9 @@ If a question can be answered by exploring the codebase, explore the codebase in This is mandatory. Before asking the user a question, first check whether the answer is already available in code, tests, configs, docs, existing specs, or task history. -Do not ask the user to confirm facts that the repository can answer. Ask only for product intent, preference, scope, risk tolerance, or decisions that remain ambiguous after inspection. +Do not ask the user to confirm facts that the repository can answer. Ask only +for research intent, scope, evidence expectations, preferences, risk tolerance, +or decisions that remain ambiguous after inspection. --- diff --git a/packages/cli/src/templates/common/skills/break-loop.md b/packages/cli/src/templates/common/skills/break-loop.md index 10f5c56..825d161 100644 --- a/packages/cli/src/templates/common/skills/break-loop.md +++ b/packages/cli/src/templates/common/skills/break-loop.md @@ -1,6 +1,13 @@ # Break the Loop - Deep Bug Analysis -When debug is complete, use this for deep analysis to break the "fix bug -> forget -> repeat" cycle. +The main agent triggers this skill for repeated technical failures, not for a +normal negative scientific result. Dispatch `planner:root_cause` to separate +code, environment, data-pipeline, and experiment-contract causes; dispatch a +coder only after a bounded repair is identified. Evidence or statistical doubt +goes to the corresponding reviewer instead. + +Use it to break the "fix bug -> forget -> repeat" cycle after failed technical +repairs or after a difficult technical defect is resolved. --- @@ -108,18 +115,13 @@ Three levels of insight: --- -## After Analysis: Immediate Actions - -**IMPORTANT**: After completing the analysis above, you MUST immediately: - -1. **Update spec/guides** - Don't just list TODOs, actually update the relevant files: - - If it's a cross-platform issue → update `cross-platform-thinking-guide.md` - - If it's a cross-layer issue → update `cross-layer-thinking-guide.md` - - If it's a code reuse issue → update `code-reuse-thinking-guide.md` - - If it's domain-specific → update `backend/*.md` or `frontend/*.md` - -2. **Sync templates** - After updating `.trellis/spec/`, sync to `src/templates/markdown/spec/` - -3. **Commit the spec updates** - This is the primary output, not just the analysis text +## After Analysis: Bounded Actions -> **The analysis is worthless if it stays in chat. The value is in the updated specs.** +1. Record the testable root cause, smallest diagnostic, and repair boundary in + the planner result. +2. Return implementation repair to a validated `coder:repair` dispatch. +3. If the result reveals reviewed, reusable engineering knowledge, let the main + agent evaluate `update-spec` near closure. A `coder:configuration` worker + writes it and a reviewer verifies it. +4. Do not edit specs, sync templates, commit files, or reinterpret a negative + research result from this analysis skill. diff --git a/packages/cli/src/templates/common/skills/check.md b/packages/cli/src/templates/common/skills/check.md index 59f9da1..ec1811b 100644 --- a/packages/cli/src/templates/common/skills/check.md +++ b/packages/cli/src/templates/common/skills/check.md @@ -1,5 +1,14 @@ # Code Quality Check +The main agent triggers this skill only after a code, test, or configuration +work package changes implementation. A `runner` executes tests, lint, typecheck, +and builds; an independent `reviewer:quality` judges correctness, scope, and +regressions. The coder's own success statement is not acceptance. + +Pure experiment and literature packages skip this skill. Code quality checks +do not approve evidence, statistics, claims, or task closure; those belong to +the matching reviewer profile. + Comprehensive quality verification for recently written code. Combines spec compliance, cross-layer safety, and pre-commit checks. --- diff --git a/packages/cli/src/templates/common/skills/grill-me.md b/packages/cli/src/templates/common/skills/grill-me.md index 35fe815..c15569a 100644 --- a/packages/cli/src/templates/common/skills/grill-me.md +++ b/packages/cli/src/templates/common/skills/grill-me.md @@ -1,10 +1,17 @@ # Trellis Grill Me -Use this skill after the initial repository-first clarification pass, when the task still has requirement gaps that only the user can answer. +The main agent uses this skill after repository-first analysis only when a +material research decision remains unresolved. Typical triggers are a changed +hypothesis, functional model architecture, dataset, split, preprocessing, +objective/loss, metric definition, baseline, claim scope, or explicit user +request. Task size alone is not a trigger. ## Purpose -Drive a strict follow-up interview to tighten `prd.md` before implementation starts. +Resolve the smallest decision needed to make the research contract testable. +A `planner` may compare methods or experimental designs, but cannot approve a +high-risk change. The main agent leads user discussion and delegates every task +artifact update. This is the Trellis-built-in replacement for external `grill-me` dependency patterns. Do not rely on any local third-party skill path. @@ -13,7 +20,8 @@ This is the Trellis-built-in replacement for external `grill-me` dependency patt Use this skill only when: - a Trellis task already exists - repository-answerable questions have already been resolved through inspection -- the remaining uncertainty is about product intent, scope, preferences, trade-offs, or risk tolerance +- the remaining uncertainty is about research intent, scope, method trade-offs, + evidence standards, claim boundaries, preferences, or risk tolerance Do **not** use this skill for questions the codebase can answer directly. diff --git a/packages/cli/src/templates/common/skills/improve-codebase-architecture.md b/packages/cli/src/templates/common/skills/improve-codebase-architecture.md index d40fa06..acb85fc 100644 --- a/packages/cli/src/templates/common/skills/improve-codebase-architecture.md +++ b/packages/cli/src/templates/common/skills/improve-codebase-architecture.md @@ -5,6 +5,13 @@ description: "Active codebase architecture analysis and review. Three modes: (A) # Trellis Improve Codebase Architecture +This is an explicit software-architecture skill. The main agent does not trigger +it merely because a research task changes a neural-network or algorithmic model +architecture. Research model decisions belong to a focused grill and +`planner:research_design` or `planner:method_selection`; this skill applies only +when module boundaries, interfaces, dependencies, or maintainability need +guidance or independent review. + This skill operates in three modes depending on context. Read the dispatch prompt to determine which mode applies. --- diff --git a/packages/cli/src/templates/common/skills/tdd.md b/packages/cli/src/templates/common/skills/tdd.md index 639f9ff..c02dd71 100644 --- a/packages/cli/src/templates/common/skills/tdd.md +++ b/packages/cli/src/templates/common/skills/tdd.md @@ -2,6 +2,12 @@ Use this skill when the task's chosen development strategy is TDD, or when the user explicitly asks to work test-first. +The main agent triggers it only for deterministic software behavior inside a +code work package. A `coder` writes tests and implementation; a `runner` +executes them. TDD may verify data transforms, model interfaces, metrics code, +and reproducibility utilities, but passing tests do not validate a hypothesis +or establish experimental evidence. + ## Purpose Turn a task into observable behavior slices and drive implementation through failing tests first. diff --git a/packages/cli/src/templates/common/skills/update-spec.md b/packages/cli/src/templates/common/skills/update-spec.md index b6c845f..d09f836 100644 --- a/packages/cli/src/templates/common/skills/update-spec.md +++ b/packages/cli/src/templates/common/skills/update-spec.md @@ -5,6 +5,12 @@ needed, but it must delegate every file update through a validated `coder:configuration` dispatch. The main agent only checks the returned result and does not edit a spec directly. +Evaluate this once near closure or when a reviewed durable contract is +established. Do not run it after every work package. One experiment, one metric +improvement, an unreviewed interpretation, or a draft claim is not durable +project knowledge. A reviewer checks the proposed spec against evidence before +the main agent accepts it. + When you learn something valuable (from debugging, implementing, or discussion), use this to update the relevant code-spec documents. **Timing**: After completing a task, fixing a bug, or discovering a new pattern diff --git a/packages/cli/src/templates/markdown/agents.md b/packages/cli/src/templates/markdown/agents.md index 3c9cc1f..d14ac51 100644 --- a/packages/cli/src/templates/markdown/agents.md +++ b/packages/cli/src/templates/markdown/agents.md @@ -16,6 +16,14 @@ If you're using Codex or another agent-capable tool, additional project-scoped h - `.agents/skills/` — reusable Trellis skills - `.codex/agents/` — optional custom subagents +## Research Collaboration Contract + +- Prefer truth and evidence over agreement. State material assumptions and uncertainty, and raise evidence-backed disagreement when it changes the conclusion. +- The user approves research trade-offs. Execute ordinary reversible work directly; with limited uncertainty, state the assumption and proceed. +- Pause for discussion only for high-risk research changes. A prompt cannot replace the structured decision or experiment record required by the workflow. +- If evidence conflicts or a critical assumption fails, mark the task blocked and use an approved amendment before continuing. +- Keep context progressive and minimal: start from the Task Capsule and direct references, then read larger records only when needed. + Managed by Trellis. Edits outside this block are preserved; edits inside may be overwritten by a future `research-trellis update`. diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 2d5062b..f2afb4b 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -9,7 +9,6 @@ import json import os import re -import shlex import subprocess import sys from fnmatch import fnmatchcase @@ -63,106 +62,7 @@ "hermes_claim_reviewer", "hermes_scientist", } -INLINE_SCRIPT_FLAGS = { - "python": {"-c"}, - "python3": {"-c"}, - "node": {"-e", "--eval", "-p", "--print"}, - "nodejs": {"-e", "--eval", "-p", "--print"}, - "perl": {"-e"}, - "ruby": {"-e"}, -} -INLINE_SCRIPT_FLAG_PREFIXES = { - "node": ("--eval=", "--print="), - "nodejs": ("--eval=", "--print="), -} -INLINE_SCRIPT_SHORT_EVAL_FLAGS = { - "python": {"c"}, - "python3": {"c"}, - "node": {"e", "p"}, - "nodejs": {"e", "p"}, - "perl": {"e", "E"}, - "ruby": {"e"}, -} -INLINE_SCRIPT_SHORT_COMBO_FLAGS = { - "node": {"e", "p"}, - "nodejs": {"e", "p"}, - "perl": {"e", "E", "w"}, - "ruby": {"e", "w"}, -} -INLINE_FILE_API_MARKERS = ( - "require('fs')", - 'require("fs")', - "require('node:fs')", - 'require("node:fs")', - "node:fs", - "from 'fs'", - 'from "fs"', - "from 'node:fs'", - 'from "node:fs"', - "from os import", - "import os", - "from shutil import", - "import shutil", - "from pathlib import", - "import pathlib", - "open(", - "Path(", - "write_text(", - "write_bytes(", - ".write(", - ".unlink(", - ".copyFile(", - ".rename(", - ".mkdir(", - "os.remove(", - "os.unlink(", - "os.rename(", - "os.mkdir(", - "os.makedirs(", - "shutil.copyfile(", - "shutil.copy(", - "shutil.move(", - "shutil.rmtree(", - "writeFile(", - "appendFile(", - "copyFile(", - "rename(", - "mkdir(", - "rm(", - "unlink(", - "unlink ", - "writeFileSync(", - "appendFileSync(", - "copyFileSync(", - "renameSync(", - "rename ", - "mkdirSync(", - "rmSync(", - "unlinkSync(", - "fs.rm(", - "fs.unlink(", - "fs.writeFile(", - "fs.appendFile(", - "fs.copyFile(", - "fs.rename(", - "fs.mkdir(", - "fs.promises.copyFile", - "fs.promises.rename", - "fs.promises.mkdir", - "FileUtils", - "FileUtils.cp(", - "FileUtils.copy(", - "FileUtils.mv(", - "FileUtils.mkdir(", - "FileUtils.mkdir_p(", - "FileUtils.rm(", - "FileUtils.rm_rf(", - "FileUtils.touch(", - "File.delete(", - "File.rename(", - "Dir.mkdir(", - "File::Copy", -) +BASH_EXECUTION_ROLES = {"coder", "runner"} def find_trellis_root(start: Path) -> Path | None: @@ -513,299 +413,39 @@ def is_main_agent_request(data: dict[str, Any]) -> bool: return True -def shell_tokens(command: str) -> list[str] | None: - try: - return shlex.split(command, posix=True) - except ValueError: - return None +def is_main_bash_allowed(command: str) -> bool: + """Allow only simple coordinator inspection and Hermes control commands. - -def has_shell_control(command: str, tokens: list[str]) -> bool: - control_tokens = { - "|", - "||", - "&", - "&&", - "<", - ">", - ">>", - "1>", - "1>>", - "2>", - "2>>", - "&>", - "&>>", - } - if "\n" in command or ";" in command or "`" in command or "$(" in command: - return True - return any( - token in control_tokens - or token.startswith((">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", "<")) - for token in tokens - ) - - -def is_allowed_record_jsonl_path(raw_path: str) -> bool: - path = raw_path.strip().replace("\\", "/") - while path.startswith("./"): - path = path[2:] - if path.startswith("/") or ".." in path.split("/"): - return False - return ( - re.fullmatch(r"\.trellis/tasks/[^/]+/hermes/[^/]+\.jsonl", path) is not None - or re.fullmatch(r"\.ai/records/[^/]+\.jsonl", path) is not None - ) - - -def is_safe_git_pathspec(raw_path: str) -> bool: - path = raw_path.strip().replace("\\", "/") - while path.startswith("./"): - path = path[2:] - if not path or path == "." or path.startswith("/") or path.startswith(":"): - return False - if any(char in path for char in "*?[]{}$~`()"): + This is intentionally a small prefix check, not a shell parser. Hermes uses + role routing and task records for coordination; it does not try to infer + every effect of arbitrary scripts. + """ + normalized = command.strip().replace("\\", "/") + # This is a coordinator convenience allowlist, not a shell-security + # parser. Compound or expanded commands are never control-plane commands. + if not normalized or any(marker in normalized for marker in (";", "&", "|", ">", "<", "`", "$", "\n")): return False - parts = path.split("/") - if any(part in {"", ".", ".."} for part in parts): - return False - for part in parts: - lower_part = part.lower() - if ( - lower_part == "env" - or ".env" in lower_part - or lower_part.endswith(".env") - ): - return False - return True - - -def split_git_options_and_paths( - args: list[str], - allowed_options: set[str], - required_options: set[str] | None = None, - *, - require_paths: bool = False, -) -> tuple[set[str], list[str]] | None: - seen_separator = False - seen_options: set[str] = set() - paths: list[str] = [] - for arg in args: - if seen_separator: - paths.append(arg) - continue - if arg == "--": - seen_separator = True - continue - if arg in allowed_options: - seen_options.add(arg) - continue - return None - - if required_options is not None and not (seen_options & required_options): - return None - if require_paths and not paths: - return None - if any(not is_safe_git_pathspec(path) for path in paths): - return None - return seen_options, paths - - -def is_readonly_git_command(tokens: list[str]) -> bool: - if len(tokens) < 2 or tokens[0] != "git": - return False - subcommand = tokens[1] - args = tokens[2:] - if subcommand == "status": - allowed_status_options = { - "--short", - "-s", - "-sb", - "--branch", - "-b", - "--porcelain", - "--porcelain=v1", - "--porcelain=v2", - "--show-stash", - "--ahead-behind", - "--no-ahead-behind", - } - short_status_options = { - "--short", - "-s", - "-sb", - "--porcelain", - "--porcelain=v1", - "--porcelain=v2", - } - return ( - split_git_options_and_paths( - args, - allowed_status_options, - short_status_options, - require_paths=True, - ) - is not None - ) - if subcommand == "diff": - allowed_diff_options = { - "--name-only", - "--stat", - "--cached", - "--staged", - "--no-renames", - } - return ( - split_git_options_and_paths( - args, - allowed_diff_options, - {"--name-only", "--stat"}, - require_paths=True, - ) - is not None - ) - if subcommand == "log": - allowed_log_options = {"--oneline", "--stat"} - return ( - split_git_options_and_paths( - args, - allowed_log_options, - {"--oneline", "--stat"}, - require_paths=True, - ) - is not None - ) - return False - - -def is_readonly_record_cat(tokens: list[str]) -> bool: - if not tokens or tokens[0] != "cat": - return False - operands = [token for token in tokens[1:] if not token.startswith("-")] - return bool(operands) and all(is_allowed_record_jsonl_path(token) for token in operands) - - -def is_readonly_record_jq(tokens: list[str]) -> bool: - if not tokens or tokens[0] != "jq": - return False - file_reading_options = { - "-f", - "--from-file", - "--slurpfile", - "--rawfile", - "--argfile", - } - if any( - token in file_reading_options - or token.startswith(("--from-file=", "--slurpfile=", "--rawfile=", "--argfile=")) - for token in tokens[1:] - ): - return False - - positional: list[str] = [] - index = 1 - while index < len(tokens): - token = tokens[index] - if token == "--": - positional.extend(tokens[index + 1 :]) - break - if token in {"--arg", "--argjson"}: - index += 3 - continue - if token in {"--indent", "-L"}: - index += 2 - continue - if token.startswith("-") and token != "-": - index += 1 - continue - positional.append(token) - index += 1 - - if len(positional) < 2: - return False - - file_operands = positional[1:] - return bool(file_operands) and all( - is_allowed_record_jsonl_path(token) for token in file_operands - ) - - -def is_main_control_bash(command: str) -> bool: - tokens = shell_tokens(command) - if not tokens or has_shell_control(command, tokens): - return False - index = 0 - executable = command_basename(tokens[index]).casefold() - if executable not in {"python", "python3", "py"}: - return False - index += 1 - if executable == "py" and index < len(tokens) and tokens[index] == "-3": - index += 1 - if index + 1 < len(tokens) and tokens[index] == "-X": - index += 2 - if index >= len(tokens): - return False - script = tokens[index].replace("\\", "/") - while script.startswith("./"): - script = script[2:] - index += 1 - if index >= len(tokens): - return False - allowed: dict[str, set[str]] = { - ".trellis/scripts/hermes/dispatch.py": { - "create", "validate", "show", "run", "apply", "list", "status", "schema", "supersede", - }, - ".trellis/scripts/closure.py": { - "status", "next", "capsule", "validate", "package-start", "package-check", - "package-done", "package-block", "audit", "repair", "amend", "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): - return False - return ( - is_readonly_git_command(tokens) - or is_readonly_record_cat(tokens) - or is_readonly_record_jq(tokens) - or is_main_control_bash(command) - ) - - -def is_runner_bash_work(command: str) -> bool: - tokens = shell_tokens(command) or [] - if not tokens: - return True - command_name = tokens[0] - if command_name in {"npm", "pnpm", "yarn", "pytest", "rm"}: - return True - if command_name == "go" and len(tokens) > 1 and tokens[1] == "test": - return True - if command_name == "cargo" and len(tokens) > 1 and tokens[1] == "test": - return True - if command_name == "git" and len(tokens) > 1 and tokens[1] in {"add", "commit", "push"}: - return True - return "pytest" in tokens - - -def main_agent_bash_denial(command: str) -> str: - next_step = ( - "dispatch a runner subagent" - if is_runner_bash_work(command) - else "dispatch the appropriate researcher or planner subagent" - ) - return ( - "Hermes Runtime: main agent firewall denied Bash. Main agent is " - "coordinator only and may run only read-only routing commands: " - "git status --short -- , git diff --name-only/--stat -- , " - "git log --oneline/--stat -- , " - "cat/jq against RecordBus JSONL, or parameterized Hermes " - "dispatch/closure/task control-plane commands. " - f"Next step: {next_step} with a task_card." + prefixes = ( + "git status", + "git diff", + "git log", + "cat .trellis/tasks/", + "cat .ai/records/", + "jq ", + "python ./.trellis/scripts/task.py ", + "python3 ./.trellis/scripts/task.py ", + "python .trellis/scripts/task.py ", + "python3 .trellis/scripts/task.py ", + "python ./.trellis/scripts/closure.py audit ", + "python3 ./.trellis/scripts/closure.py audit ", + "python .trellis/scripts/closure.py audit ", + "python3 .trellis/scripts/closure.py audit ", + "python ./.trellis/scripts/hermes/dispatch.py ", + "python3 ./.trellis/scripts/hermes/dispatch.py ", + "python .trellis/scripts/hermes/dispatch.py ", + "python3 .trellis/scripts/hermes/dispatch.py ", ) + return normalized.startswith(prefixes) def main_agent_firewall_reason(data: dict[str, Any]) -> str | None: @@ -820,15 +460,14 @@ def main_agent_firewall_reason(data: dict[str, Any]) -> str | None: if tool_name != "Bash": return None command = bash_command_from_tool_input(data.get("tool_input", {})) - if command is None: - return ( - "Hermes Runtime: main agent firewall denied Bash because the " - "command could not be parsed. Next step: dispatch a runner " - "subagent with a task_card." - ) - if is_main_readonly_bash(command): + if command and is_main_bash_allowed(command): return None - return main_agent_bash_denial(command) + return ( + "Hermes Runtime: main agent firewall denied Bash. Main agent may inspect " + "task state or use Hermes control commands, but implementation, tests, " + "and experiments must be delegated to a runner subagent or the appropriate " + "specialist subagent." + ) def no_active_task_tool_denial(tool_name: str) -> str: @@ -839,185 +478,6 @@ def no_active_task_tool_denial(tool_name: str) -> str: ) -def looks_like_bash_write(command: str) -> bool: - write_markers = ( - ">", - "tee ", - "write_text", - "write_bytes", - ".write(", - ".unlink(", - ".copyFile(", - ".rename(", - ".mkdir(", - "open(", - "os.remove(", - "os.unlink(", - "os.rename(", - "os.mkdir(", - "os.makedirs(", - "shutil.copyfile(", - "shutil.copy(", - "shutil.move(", - "shutil.rmtree(", - "writeFileSync(", - "appendFileSync(", - "copyFileSync(", - "renameSync(", - "mkdirSync(", - "copyFile(", - "rename(", - "mkdir(", - "rmSync(", - "unlinkSync(", - "FileUtils.cp(", - "FileUtils.copy(", - "FileUtils.mv(", - "FileUtils.mkdir(", - "FileUtils.mkdir_p(", - "FileUtils.rm(", - "FileUtils.rm_rf(", - "FileUtils.touch(", - "File.delete(", - "File.rename(", - "Dir.mkdir(", - "File::Copy", - "Path(", - "touch ", - "mkdir ", - "rm ", - "mv ", - "cp ", - "sed -i", - "perl -pi", - "cat <<", - ) - return any(marker in command for marker in write_markers) - - -def command_basename(token: str) -> str: - return Path(token).name - - -def inline_script_code(tokens: list[str], interpreter_index: int) -> str | None: - interpreter = command_basename(tokens[interpreter_index]) - flags = INLINE_SCRIPT_FLAGS.get(interpreter) - if flags is None: - return None - prefixes = INLINE_SCRIPT_FLAG_PREFIXES.get(interpreter, ()) - short_eval_flags = INLINE_SCRIPT_SHORT_EVAL_FLAGS.get(interpreter, set()) - short_combo_flags = INLINE_SCRIPT_SHORT_COMBO_FLAGS.get( - interpreter, - short_eval_flags, - ) - index = interpreter_index + 1 - while index < len(tokens): - token = tokens[index] - for prefix in prefixes: - if token.startswith(prefix): - return token[len(prefix) :] - for flag in flags: - if token == flag: - if index + 1 >= len(tokens): - return "" - return tokens[index + 1] - if token.startswith("-") and not token.startswith("--") and token != "-": - short_options = token[1:] - if ( - len(short_options) > 1 - and any(option in short_eval_flags for option in short_options) - and all(option in short_combo_flags for option in short_options) - ): - if index + 1 >= len(tokens): - return "" - return tokens[index + 1] - if short_options and short_options[0] in short_eval_flags: - return short_options[1:] - index += 1 - return None - - -def inline_script_has_unparsed_file_api(tokens: list[str], interpreter_index: int) -> bool: - code = inline_script_code(tokens, interpreter_index) - if code is None: - return False - if not code.strip(): - return True - return any(marker in code for marker in INLINE_FILE_API_MARKERS) - - -def add_bash_target(targets: list[str], root: Path, value: str) -> None: - if value in {"-", "/dev/null"}: - return - if value.startswith("&"): - return - add_unique_target(targets, root, value) - - -def is_shell_separator(token: str) -> bool: - return token in {"|", "||", "&", "&&", ";"} or token.endswith(";") - - -def extract_bash_targets(root: Path, command: str) -> tuple[list[str], str | None]: - try: - tokens = shlex.split(command, posix=True) - except ValueError as exc: - return [], f"cannot safely parse Bash write targets: {exc}" - - if any(command_basename(token) == "ln" for token in tokens): - return [], "link creation is not allowed for Hermes dispatches" - - targets: list[str] = [] - redirect_tokens = {">", ">>", "1>", "1>>", "2>", "2>>", "&>", "&>>", ">|"} - redirect_prefix = re.compile(r"^(?:\d?>{1,2}|&>{1,2}|\|>)(.+)$") - write_like_but_unparsed = False - - index = 0 - while index < len(tokens): - token = tokens[index] - if token in redirect_tokens: - if index + 1 >= len(tokens): - return targets, "cannot safely parse Bash write targets: missing redirect target" - add_bash_target(targets, root, tokens[index + 1]) - index += 2 - continue - match = redirect_prefix.match(token) - if match: - add_bash_target(targets, root, match.group(1)) - index += 1 - continue - if token == "tee": - tee_index = index + 1 - found_tee_target = False - seen_tee_separator = False - while tee_index < len(tokens): - candidate = tokens[tee_index] - if is_shell_separator(candidate): - break - if not seen_tee_separator and candidate == "--": - seen_tee_separator = True - tee_index += 1 - continue - if not seen_tee_separator and candidate.startswith("-"): - tee_index += 1 - continue - add_bash_target(targets, root, candidate) - found_tee_target = True - tee_index += 1 - if not found_tee_target: - return targets, "cannot safely parse Bash write targets: missing tee target" - index = tee_index - continue - if command_basename(token) in INLINE_SCRIPT_FLAGS: - if inline_script_has_unparsed_file_api(tokens, index): - write_like_but_unparsed = True - index += 1 - - if write_like_but_unparsed: - return targets, "cannot safely parse Bash write targets from write-like command" - return targets, None - - def extract_job_id_from_tool_input(value: Any) -> str | None: if isinstance(value, dict): for key in ("job_id", "jobId", "hermes_job_id", "hermesJobId"): @@ -1190,6 +650,29 @@ def completed_coder_results( return results +def completed_noncode_results( + records: list[dict[str, Any]], + root: Path, +) -> list[dict[str, Any]]: + """Return completed research/configuration results when code work is absent.""" + cards = task_cards_by_job(records, root) + results: list[dict[str, Any]] = [] + for record in records: + if record.get("type") != "result" or not is_completion_handoff(record): + continue + job_id = record_job_id(record) + card = cards.get(job_id or "") + if card is None: + continue + role = card.get("role") + profile = card.get("profile") + if role in {"planner", "researcher", "runner"} or ( + role == "coder" and profile == "configuration" + ): + results.append(record) + return results + + def result_covers_changed_files(result: dict[str, Any], changed_files: list[str]) -> bool: if not changed_files: return True @@ -1231,6 +714,58 @@ def stop_completion_reason( task: str, records: list[dict[str, Any]], ) -> str | None: + changed_files = git_changed_files(root) + cards = task_cards_by_job(records, root) + code_cards = [ + card for card in cards.values() + if card.get("role") == "coder" + and card.get("profile") in {"implementation", "tests", "repair"} + ] + # The repository may already contain unrelated user changes. Route Stop by + # the current task's cards, not by global worktree cleanliness. + if not code_cards: + research_results = completed_noncode_results(records, root) + if not research_results: + return ( + "Hermes Runtime: Stop requires a completed planner, researcher, " + "runner, or configuration result before completion." + ) + run_manifest_path = root / ".trellis" / "tasks" / task / "hermes" / "run_manifest.jsonl" + if run_manifest_path.exists(): + validate_run_manifest = run_runtime( + root, + [str(runtime_root / "validate.py"), "--task", task, "--kind", "run_manifest"], + ) + if validate_run_manifest.returncode != 0: + return ( + "Hermes Runtime: run manifest is invalid; inspect task " + "Hermes logs locally." + ) + run_manifests = read_run_manifest_index(root, task) + for result in research_results: + job_id = record_job_id(result) + card = cards.get(job_id or "") + if job_id is None or card is None: + continue + if card.get("role") == "runner" and not runner_result_has_passing_test( + result, run_manifests + ): + continue + review_records = related_records( + records, + cards, + job_id, + "reviewer", + {"checkpoint", "result"}, + {"quality", "evidence", "claim", "safety", "closure", "statistics"}, + ) + if review_records: + return None + return ( + "Hermes Runtime: Stop requires a related independent reviewer record; " + "formal runner work also requires a successful run manifest." + ) + coder_results = completed_coder_results(records, root) if not coder_results: return ( @@ -1238,7 +773,6 @@ def stop_completion_reason( "worker_records.jsonl before completion." ) - changed_files = git_changed_files(root) matching_results = [ result for result in coder_results if result_covers_changed_files(result, changed_files) ] @@ -1263,7 +797,6 @@ def stop_completion_reason( "Hermes logs locally." ) - cards = task_cards_by_job(records, root) run_manifests = read_run_manifest_index(root, task) for result in matching_results: job_id = record_job_id(result) @@ -1314,9 +847,6 @@ def guard_tool_input_permissions( tool_name = str(data.get("tool_name") or "") tool_input = data.get("tool_input", {}) if tool_name == "Bash": - command = bash_command_from_tool_input(tool_input) - if command is None: - return "Hermes Runtime: cannot safely parse Bash tool_input; denying by default." agent_id = data.get("agent_id") if isinstance(agent_id, str) and agent_id: scripts_dir = root / ".trellis" / "scripts" @@ -1332,17 +862,9 @@ def guard_tool_input_permissions( except Exception: return "Hermes Runtime: Bash caller has no valid dispatch binding." role = str(bound_dispatch.get("role") or "") - if role in {"planner", "researcher"}: + if role not in BASH_EXECUTION_ROLES: return f"Hermes Runtime: {role} dispatch cannot execute Bash." - if role == "reviewer" and not is_readonly_git_command(shell_tokens(command) or []): - return "Hermes Runtime: reviewer Bash is limited to scoped read-only git inspection." - target_files, parse_error = extract_bash_targets(root, command) - if parse_error is not None: - return f"Hermes Runtime: {parse_error}; denying by default." - if not target_files and not looks_like_bash_write(command): - return None - if not target_files: - return "Hermes Runtime: cannot safely parse Bash write targets; denying by default." + return None else: target_files = collect_tool_target_files(root, tool_name, tool_input) if not target_files: 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 5618782..18602a0 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -71,9 +71,6 @@ ("Project constraints", "CONSTRAINTS.md", "fixed boundaries and approvals"), ) -_HANDOFF_REVISION_RE = re.compile(r"") - - def _build_project_context_index(root: Path) -> str: """List main-agent project inputs without loading the documents themselves.""" project_dir = root / ".trellis" / "project" @@ -244,6 +241,7 @@ def compact(value: object, limit: int) -> str: "", f"Active task: {task_label}; phase={phase_label}; current package={current_package}; next={action}", f"Before planning, dispatching, or continuing work, read {task_ref}/task.json.", + "Classify this request as an in-scope continuation, a bounded plan change, or a new task. Do not regenerate existing work packages for an in-scope continuation.", ] if handoff_notice: lines.append(handoff_notice) @@ -254,22 +252,16 @@ def compact(value: object, limit: int) -> str: return "\n".join(lines) -def _handoff_state(task_dir: Path, revision: object) -> tuple[str, str | None]: - """Return a fresh handoff marker or a compact stale/missing explanation.""" +def _handoff_state(task_dir: Path) -> tuple[str, str | None]: + """Return a lightweight marker for an optional handoff summary.""" handoff = task_dir / "HANDOFF.md" if not handoff.is_file(): - return "missing", "No current HANDOFF.md exists; use the Task Capsule and task state." - if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: - return "invalid-task-revision", "Ignore HANDOFF.md until task.json has a valid Hermes revision." + return "missing", None try: - prefix = handoff.read_text(encoding="utf-8", errors="replace")[:512] - marker = _HANDOFF_REVISION_RE.search(prefix) stamp = handoff.stat().st_mtime_ns except OSError: - return "unreadable", "HANDOFF.md could not be read; use task.json as the source of truth." - if marker is None or int(marker.group(1)) != revision: - return f"stale:{marker.group(1) if marker else 'legacy'}:{stamp}", "HANDOFF.md is stale for this task revision; do not rely on it. Regenerate it before a handoff." - return f"fresh:{revision}:{stamp}", None + return "unreadable", None + return f"available:{stamp}", None def _context_reset_requested(input_data: dict) -> bool: @@ -308,6 +300,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None is_closure_task, research_route_rule, research_route_summary, + skill_route_guidance, ) except Exception: return None @@ -319,6 +312,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None next_action = closure_next_action(data) route_summary = research_route_summary(data) route_rule = research_route_rule(data) + skill_route = skill_route_guidance(data) key = getattr(active, "context_key", None) anchor_path = root / ".trellis" / ".runtime" / "sessions" / f"{key}.json" if isinstance(key, str) and key else None anchor: dict = {} @@ -331,18 +325,18 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None context_reset = _context_reset_requested(input_data) if context_reset: previous = None - handoff_marker, handoff_problem = _handoff_state(task_dir, revision) + handoff_marker, handoff_problem = _handoff_state(task_dir) seen_handoff_marker = previous.get("handoff_marker") if isinstance(previous, dict) else None if handoff_marker == seen_handoff_marker: handoff_notice = None - elif handoff_problem: - handoff_notice = handoff_problem - else: + elif handoff_marker.startswith("available:"): try: handoff_ref = task_dir.relative_to(root).as_posix() except ValueError: handoff_ref = ".trellis/tasks/" + task_dir.name - handoff_notice = f"Read {handoff_ref}/HANDOFF.md once before continuing; it matches the current task revision." + handoff_notice = f"If this session resumes after a pause or handoff, read {handoff_ref}/HANDOFF.md once. task.json remains the source of truth." + else: + handoff_notice = handoff_problem resume_notice = _build_task_resume_notice( root, task_dir, @@ -358,6 +352,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None "phase": phase, "next_action": next_action, "route": route_summary, + "skill_route": skill_route, "handoff_marker": handoff_marker, } if anchor_path: @@ -367,9 +362,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None anchor_path.write_text(json.dumps(anchor, ensure_ascii=False) + "\n", encoding="utf-8") except OSError: pass - force_capsule = context_reset or ( - handoff_marker.startswith("fresh:") and handoff_marker != seen_handoff_marker - ) + force_capsule = context_reset if ( isinstance(previous, dict) and previous.get("task") == task_id @@ -377,7 +370,7 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None and not force_capsule ): return ( - f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\n", + f"\nHermes closure task: {task_id}; anchor revision {revision} remains active.\n{route_summary}\nRoute rule: {route_rule}\nSkill route: {skill_route}.\n", None, resume_notice, ) @@ -389,13 +382,14 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None if previous.get("route") != current["route"]: changes.append(route_summary) changes.append("Route rule: " + route_rule) + changes.append("Skill route: " + skill_route + ".") return ( "\n" + "\n".join(changes) + "\n", build_capsule(task_dir, data, root), resume_notice, ) return ( - f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nNext: {next_action}\n", + f"\nHermes closure task: {task_id}; phase={phase}.\n{route_summary}\nRoute rule: {route_rule}\nSkill route: {skill_route}.\nNext: {next_action}\n", build_capsule(task_dir, data, root), resume_notice, ) diff --git a/packages/cli/src/templates/shared-hooks/session-start.py b/packages/cli/src/templates/shared-hooks/session-start.py index 500eeb0..e77e44a 100644 --- a/packages/cli/src/templates/shared-hooks/session-start.py +++ b/packages/cli/src/templates/shared-hooks/session-start.py @@ -576,7 +576,7 @@ def _get_task_status(trellis_dir: Path, input_data: dict) -> str: return ( f"Status: PLANNING\nTask: {task_title}\n" f"Present: {present_line}\n" - "Next-Action: Load `trellis-brainstorm` and write `prd.md`. Stay in planning." + "Next-Action: Analyze intent, scope, done criteria and research route; load `trellis-brainstorm` only if requirements remain unresolved. Stay in planning." ) if task_status == "planning": diff --git a/packages/cli/src/templates/trellis/config.yaml b/packages/cli/src/templates/trellis/config.yaml index 707907b..f27077c 100644 --- a/packages/cli/src/templates/trellis/config.yaml +++ b/packages/cli/src/templates/trellis/config.yaml @@ -91,9 +91,9 @@ max_journal_lines: 2000 # Hermes Agent Context Firewall #------------------------------------------------------------------------------- # Claude uses approved Agent hooks for enforced prompt replacement and result -# sanitization. Codex native custom agents are advisory; strict mode invokes -# codex exec with the bundled result schema. Lean warns without a live -# heartbeat, standard requires hooks or strict, and publication fails closed. +# sanitization. Codex custom agents use the same compact dispatch and result +# contract in the current project workspace. Closure requirements remain based +# on recorded evidence and review, not an isolated execution mode. # # hermes: -# codex_mode: native # native (advisory) | strict (enforced) +# codex_mode: native diff --git a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md index 438ddf1..d131e75 100644 --- a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md +++ b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md @@ -38,6 +38,46 @@ research-design, or scope decision remains unresolved, or when the user explicitly asks for one. Task size, task age, and the mere presence of multiple work packages are not reasons to start a grill. +Prefer truth and evidence over agreement. State material assumptions and +uncertainty, and raise evidence-backed disagreement when it affects the +conclusion. The user approves research trade-offs. A prompt is guidance, not a +replacement for the structured task, decision, experiment, or evidence record. + +Use three intervention levels without adding workflow stages: + +* ordinary reversible work: execute directly with the current task boundary; +* limited uncertainty: state the material assumption, then proceed; +* high-risk research change: pause for focused discussion and user approval, + then record the decision before validation. + +If evidence conflicts or a critical assumption fails, mark the task blocked +and continue only through an approved amendment. Do not apply this pause or a +grill gate to ordinary delivery or frozen-protocol execution. + +Treat each request as one of three cases before routing it: + +* a new actionable task: perform the full short analysis and record 1-4 + observable-result work packages after task-creation consent; +* an in-scope continuation: keep the current package and do not regenerate the + plan merely because the user added detail; +* a scope or research-contract change: use a bounded amendment, revalidate, and + require human approval for high-risk research fields. + +A newly completed high-risk exploration grill must carry a repository- or +task-relative `decision_ref` to an existing `prd.md`, `design.md`, or equivalent +record. That record contains Decision, Rationale, Evidence, Alternatives, and +Failure Conditions. Events and the Task Capsule carry only the reference, never +the decision body. Legacy tasks with `grill_completed=true` and no reference +remain valid with a warning; the next research amendment clears the old +reference and requires a fresh decision. + +Only an exploration task whose `research_change_fields` includes `dataset`, +`split`, or `preprocessing` requires `experiment.yaml.data_preflight`. Validate +its repository-local input manifest or data path and hash, plus a checks record +covering schema, missing values, duplicates, and split leakage. Runner declares +both files with `--input` before command execution. All other tasks have no new +data-preflight step. + At session start, use the project-context index for `.trellis/project/` before accepting or splitting a request. Read the relevant background, research-plan, and constraint document on demand; their contents must not be copied into the @@ -66,15 +106,41 @@ the main agent must also trigger their underlying process when the state calls for focused decision resolution or durable project knowledge. It may lead the user discussion itself, but any task or spec write must be delegated through a validated planner or coder dispatch; the main agent only verifies the result. -`HANDOFF.md` is written only by the restricted `coder:configuration` handoff -dispatch, never by the main agent. +For pauses, phase changes, blocked work, or context compaction, request a +compact `HANDOFF.md` update. It supports recovery but never grants authority, +changes task state, or blocks task completion. + +## 1.2 Research Skill Ownership + +Skills are loaded on demand from the recorded phase and the actual work, with +no fixed per-turn count. When several are necessary, use them in dependency +order and reuse their recorded outputs by reference. + +* Main leads `brainstorm` only for unresolved requirements and `grill-me` only + for a material research decision. A planner may propose options but cannot + approve the user's research choice. +* Main triggers `before-dev` for code work; coder uses it. TDD is opt-in through + the recorded strategy, with coder implementing and runner executing tests. +* Main triggers code checking after implementation; runner executes tests and + builds, while an independent reviewer judges quality. Code checking never + establishes scientific evidence. +* Main triggers `hermes-research` for formal runs or evidence. Runner records + runs and artifacts; reviewer judges evidence, statistics, and claims. +* `break-loop` is for repeated technical failures. A negative scientific result + goes to planner/reviewer analysis and must not be treated as a software bug. +* Software architecture analysis is explicit and does not decide scientific + model architecture. +* Main may identify durable knowledge, but `update-spec` writes through + `coder:configuration` and is independently reviewed. One run is insufficient. +* Main requests handoff writing through `coder:configuration`. At closure, + reviewer checks closure and runner performs final validation/archive actions. +* `trellis-meta` is reserved for changes to Trellis itself. ## 2. Main Agent Hard Limits As Main Agent, you must not directly: -* write files with `Edit`, `Write`, `MultiEdit`, `apply_patch`, shell - redirection, or write-like scripts; +* write files with `Edit`, `Write`, `MultiEdit`, `apply_patch`, or `Bash`; * modify source code; * modify metrics; * modify dataset splits; diff --git a/packages/cli/src/templates/trellis/hermes/config.yaml b/packages/cli/src/templates/trellis/hermes/config.yaml index 772ed74..85d0c95 100644 --- a/packages/cli/src/templates/trellis/hermes/config.yaml +++ b/packages/cli/src/templates/trellis/hermes/config.yaml @@ -1,8 +1,8 @@ # Hermes research overlay config. -hermes_version: "0.1.0-mvp" -runtime_scope: "deployment candidate hardening" -runtime_boundary: "not an OS sandbox" +hermes_version: "0.7.1-beta.0" +runtime_scope: "lightweight research collaboration" +runtime_boundary: "runs directly in the project workspace" # Task-scoped Hermes records are the source of truth for research work. records_dir: ".trellis/tasks/{task}/hermes" @@ -66,7 +66,6 @@ context_firewall: raw_trace_publish: false claude_mode: "hooks-enforced" codex_native: "advisory" - codex_strict: "codex exec --output-schema --json -o" async_workers: heartbeat_interval: "5m" @@ -77,16 +76,12 @@ async_workers: runtime: scripts_dir: ".trellis/scripts/hermes" - sandbox: - mode: "none" - required: false - note: "Local fail-closed checks only; not an OS sandbox." commands: append_record: "python3 ./.trellis/scripts/hermes/record.py append --task {task} --record-type worker --json '{json}'" dispatch_create: "python3 ./.trellis/scripts/hermes/dispatch.py create --task {task} --role {role} --profile {profile} --objective {objective}" dispatch_validate: "python3 ./.trellis/scripts/hermes/dispatch.py validate --task {task} --job-id {job_id}" dispatch_show: "python3 ./.trellis/scripts/hermes/dispatch.py show --task {task} --job-id {job_id}" - dispatch_run_codex_strict: "python3 ./.trellis/scripts/hermes/dispatch.py run --task {task} --job-id {job_id} --platform codex --mode strict" + dispatch_run: "python3 ./.trellis/scripts/hermes/dispatch.py run --task {task} --job-id {job_id} --platform {platform}" dispatch_list: "python3 ./.trellis/scripts/hermes/dispatch.py list --task {task}" validate_worker: "python3 ./.trellis/scripts/hermes/validate.py --task {task} --kind worker" init_experiment: "python3 ./.trellis/scripts/hermes/experiment.py init --task {task}" diff --git a/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml b/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml index 13ee7d5..8c92ec2 100644 --- a/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml +++ b/packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml @@ -12,9 +12,12 @@ environment: shell: "bash" allowed_commands: - "python3" -sandbox: - mode: "none" - required: false - image: "" - command: "" artifact_dir: ".trellis/tasks/{task}/hermes/experiments" + +# Exploration tasks changing dataset, split, or preprocessing add: +# data_preflight: +# source: "source name" +# version: "version" +# input_manifest: "repo/path/to/input-manifest.json" +# hash: "sha256:..." +# checks_ref: "repo/path/to/data-checks.yaml" diff --git a/packages/cli/src/templates/trellis/hermes/records/recordbus.md b/packages/cli/src/templates/trellis/hermes/records/recordbus.md index 781a580..f3614c5 100644 --- a/packages/cli/src/templates/trellis/hermes/records/recordbus.md +++ b/packages/cli/src/templates/trellis/hermes/records/recordbus.md @@ -4,8 +4,8 @@ RecordBus is the only trusted communication channel between agents. Chat messages can hint at work, but they are not facts. A task is not complete until the relevant record exists under `.trellis/tasks//hermes/`. -This is deployment candidate hardening, not a production-ready platform. JSONL -records are not tamper-proof storage, and Hermes is not an OS sandbox. +Hermes is a lightweight local collaboration tool. JSONL records preserve task +history but are not a security or isolation boundary. ## Role And Task Card Guide @@ -20,8 +20,8 @@ Formal Agent Context Firewall support is limited to Claude and Codex. A validated dispatch lives at `.trellis/tasks//hermes/dispatches/.dispatch.json`; its sanitized result lives beside it as `.result.json`. Claude hooks replace Agent input -and output. Codex native agents are advisory; strict mode uses `codex exec ---output-schema --json -o` through `dispatch.py run`. +and output. Codex uses the same compact dispatch and result protocol in the +current project workspace. Use task cards to assign one bounded worker at a time. Canonical roles are `planner`, `researcher`, `coder`, `runner`, and `reviewer`. The optional @@ -69,9 +69,8 @@ store task-specific evidence, claims, approvals, worker logs, or review results. - Provenance records go to `provenance_ledger.jsonl` and should include dataset, model, code, env, and artifact refs with hash, version, or source metadata. -- Audit records go to `audit_ledger.jsonl` for security gates, external write - boundaries, secret redaction, sandbox gate decisions, and approval boundary - events. +- Audit records go to `audit_ledger.jsonl` for evidence, review, approval, and + task-boundary events. - Plan change records go to `plan_change_log.jsonl` when a PRD, contract, experiment config, or other research plan changes. Append a new record instead of rewriting the old plan trail. @@ -100,10 +99,8 @@ Experiment work should keep its source config in `experiment.yaml` and append execution details to `run_manifest.jsonl` so the run stays traceable without mixing it into chat history. Each manifest entry should include command, cwd, env_summary, inputs, outputs, exit_code, started_at, and finished_at. -`allowed_commands` is a command allowlist, not a strong command sandbox. If -`python3` is allowed, arbitrary Python code can still be executed by that -command. Use exact command patterns and external OS-level isolation for stronger -controls. +`allowed_commands` is a reproducibility contract for the task, not a security +boundary. Use exact command patterns so later runs are easy to understand. Runner-managed commands should write stdout and stderr logs under the task Hermes directory and include those log paths in the manifest. Failed commands must keep the original exit code and append a rejection that points to the @@ -207,7 +204,7 @@ Append a `provenance` record to lock the refs used by an experiment. Append an `audit` record when a security or approval boundary is checked. ```json -{"type":"audit","id":"au-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","event":"security_gate|external_write_boundary|secret_redaction|sandbox_gate|approval_boundary","actor":"runner.py","boundary":"allowed_commands","decision":"blocked","summary":"short reason"} +{"type":"audit","id":"au-YYYYMMDD-HHMMSS-slug","timestamp":"YYYY-MM-DDTHH:MM:SSZ","event":"evidence_check|review_check|approval_boundary","actor":"runner.py","boundary":"allowed_commands","decision":"recorded","summary":"short reason"} ``` ### plan_change diff --git a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md index 034fc43..a2f1f3a 100644 --- a/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md +++ b/packages/cli/src/templates/trellis/hermes/records/subagent_protocol.md @@ -14,7 +14,7 @@ waits for human/root approval instead of inventing a local decision. Before starting a worker, create and validate one dispatch with `python3 ./.trellis/scripts/hermes/dispatch.py create`. The command writes the dispatch file and its RecordBus `task_card`. Claude Agent calls receive only -the `job_id`; Codex strict work runs through the same CLI. +the `job_id`; Codex uses the same compact dispatch and result protocol. The validated dispatch defines: diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index 7bd0529..1c86aa4 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -82,6 +82,7 @@ def parser() -> argparse.ArgumentParser: grill = subparsers.add_parser("grill", help="Record a completed exploration grill") add_common(grill) grill.add_argument("--complete", action="store_true") + grill.add_argument("--decision-ref", required=True, help="Repository- or task-relative decision record") validate = subparsers.add_parser("validate", help="Validate plan and enter ready") add_common(validate) @@ -189,8 +190,13 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "grill": if not args.complete: - raise ClosureError("use grill --complete after the exploration grill") - mark_grill_complete(task_dir, data, actor=actor) + raise ClosureError("use grill --complete --decision-ref after the exploration grill") + mark_grill_complete( + task_dir, + data, + actor=actor, + decision_ref=args.decision_ref, + ) print("exploration grill recorded") return 0 if args.command == "validate": diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index 5d02489..d9feeee 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -12,7 +12,7 @@ from typing import Any from .firewall import closure_mode_gate -from .io import file_lock, path_has_symlink, read_json, write_json_atomic, write_text_atomic +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 @@ -43,6 +43,13 @@ "execution": "execution", "exploration": "exploration", } +DECISION_SECTION_HEADINGS = { + "decision": "Decision", + "rationale": "Rationale", + "evidence": "Evidence", + "alternatives": "Alternatives", + "failure conditions": "Failure Conditions", +} PACKAGE_STATUSES = { "pending", "ready", @@ -165,6 +172,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "research_route": "delivery", "research_change_fields": [], "grill_completed": False, + "decision_ref": None, "constraints": { "excluded_platforms": [], "excluded_paths": [], @@ -384,6 +392,53 @@ def research_route_summary(data: dict[str, Any]) -> str: ) +def skill_route_guidance(data: dict[str, Any]) -> str: + """Return one compact, phase-aware skill and role routing hint.""" + phase = str(data.get("hermes_phase") or "planning") + route = research_route(data) + if phase == "planning": + if route == "exploration" and data.get("grill_completed") is not True: + return ( + "main leads a focused grill-me decision; planner may propose options, " + "but cannot approve the research change" + ) + return ( + "main analyzes intent, scope, done criteria and 1-4 outcome packages; " + "brainstorm only for unresolved requirements" + ) + if phase == "ready": + return ( + "main starts the next package and dispatches by permission; " + "do not preload unrelated skills" + ) + if phase == "running": + return ( + "coder uses before-dev for code work (TDD only when selected); " + "runner records formal experiments with hermes-research" + ) + if phase == "review": + if data.get("current_work_package"): + return ( + "runner verifies execution and reviewer checks independently; " + "trellis-check is only for code work" + ) + return ( + "reviewer:closure audits completion; main evaluates update-spec only " + "for reviewed durable knowledge" + ) + if phase == "blocked": + return ( + "main separates technical failure from a negative research result; " + "break-loop is only for repeated technical failure, handoff on pause" + ) + if phase == "closed": + return ( + "main coordinates finish-work; reviewer preserves closure independence " + "and runner performs final validation/archive commands" + ) + return "main routes only the skills justified by the current recorded state" + + def _research_change_fields(data: dict[str, Any]) -> list[str]: raw = data.get("research_change_fields") if not isinstance(raw, list): @@ -575,9 +630,11 @@ def plan_closure( raise ClosureError("research route must be delivery, execution, or exploration") data["research_route"] = research_route_name data["grill_completed"] = False + data["decision_ref"] = None if normalized_research_changes is not None: data["research_change_fields"] = normalized_research_changes data["grill_completed"] = False + data["decision_ref"] = None if not _string_list(data.get("definition_of_done")): data["definition_of_done"] = parse_prd_definition_of_done(task_dir / "prd.md") @@ -642,6 +699,7 @@ def set_research_route( "research_route": research_route(data), "research_change_fields": _research_change_fields(data), "grill_completed": data.get("grill_completed") is True, + "decision_ref": data.get("decision_ref"), } normalized_research_changes: list[str] | None = None if research_change_fields is not None: @@ -667,12 +725,14 @@ def set_research_route( ) if changed: data["grill_completed"] = False + data["decision_ref"] = None data["next_action"] = closure_next_action(data) save_task(task_dir, data) after = { "research_route": research_route(data), "research_change_fields": _research_change_fields(data), "grill_completed": data.get("grill_completed") is True, + "decision_ref": data.get("decision_ref"), } append_event( task_dir, @@ -690,6 +750,7 @@ def mark_grill_complete( data: dict[str, Any], *, actor: str, + decision_ref: str, ) -> None: ensure_closure_defaults(data) if data.get("hermes_phase") != "planning": @@ -698,9 +759,11 @@ def mark_grill_complete( raise ClosureError("grill completion is only required for exploration") if not _research_change_fields(data): raise ClosureError("exploration must record at least one research change field") - if data.get("grill_completed") is True: - return + normalized_ref, decision_errors = _validate_decision_ref(task_dir, decision_ref) + if decision_errors: + raise ClosureError("; ".join(decision_errors)) data["grill_completed"] = True + data["decision_ref"] = normalized_ref data["next_action"] = closure_next_action(data) save_task(task_dir, data) append_event( @@ -710,15 +773,28 @@ def mark_grill_complete( old_state="pending", new_state="complete", reason="exploration grill completed", + extra={"decision_ref": normalized_ref}, ) -def validate_closure(data: dict[str, Any]) -> tuple[list[str], list[str]]: +def validate_closure( + data: dict[str, Any], + task_dir: Path | None = None, +) -> tuple[list[str], list[str]]: errors: list[str] = [] warnings: list[str] = [] if data.get("closure_mode") not in CLOSURE_MODES: errors.append("closure_mode must be lean, standard, or publication") errors.extend(_validate_research_route(data)) + if research_route(data) == "exploration" and data.get("grill_completed") is True: + decision_ref = data.get("decision_ref") + if not isinstance(decision_ref, str) or not decision_ref.strip(): + warnings.append( + "legacy completed grill has no decision_ref; record one at the next research amendment" + ) + elif task_dir is not None: + _, decision_errors = _validate_decision_ref(task_dir, decision_ref) + errors.extend(decision_errors) phase = data.get("hermes_phase") closure_state = data.get("closure_state") if phase not in HERMES_PHASES: @@ -914,7 +990,7 @@ def validate_and_ready( ensure_closure_defaults(data) if data.get("closure_state") == "closed": return ["closed tasks must be reopened through closure.py plan"], [] - errors, warnings = validate_closure(data) + errors, warnings = validate_closure(data, task_dir) if errors: return errors, warnings old_phase = str(data.get("hermes_phase") or "planning") @@ -964,7 +1040,7 @@ def start_package( reason: str = "", ) -> dict[str, Any]: ensure_closure_defaults(data) - errors, _ = validate_closure(data) + errors, _ = validate_closure(data, task_dir) if errors: raise ClosureError("plan is not valid: " + "; ".join(errors)) if data.get("hermes_phase") == "planning": @@ -1204,7 +1280,7 @@ def audit_closure( ) -> dict[str, Any]: gaps: list[dict[str, Any]] = [] warnings: list[str] = [] - errors, _ = validate_closure(data) + errors, _ = validate_closure(data, task_dir) for error in errors: gaps.append(_gap(None, error, "修正 task.json closure plan 后重新 validate")) packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] @@ -1540,7 +1616,6 @@ def amend_plan( data["research_change_fields"] = _dedupe( [*previous_changes, "model_architecture"] ) - data["grill_completed"] = False blocker = "model architecture requires an independent exploration task" data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) data["closure_state"] = "open" @@ -1557,11 +1632,12 @@ def amend_plan( data["hermes_phase"] = "planning" data["status"] = "planning" if research_change is not None: + data["decision_ref"] = None + data["grill_completed"] = False data["research_route"] = "exploration" data["research_change_fields"] = _dedupe( [*_research_change_fields(data), research_change] ) - data["grill_completed"] = False data["next_action"] = closure_next_action(data) else: data["next_action"] = "Run closure.py validate after the plan amendment." @@ -1751,6 +1827,9 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None ] if current: lines.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 240)) + decision_ref = data.get("decision_ref") + if isinstance(decision_ref, str) and decision_ref.strip(): + lines.append("Decision ref: " + _clip(decision_ref.strip(), 180)) lines.append(f"Next: {_clip(closure_next_action(data), 180)}") lines.append(f"Blockers: {blockers or '-'}") refs = _context_refs(task_dir, data, root or get_repo_root())[:3] @@ -1770,12 +1849,8 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None ]) changed, integrity_warnings = _task_changed_file_records(task_dir, data) failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) - revision = data.get("hermes_revision", 0) - if not isinstance(revision, int) or isinstance(revision, bool) or revision < 0: - revision = 0 content = "\n".join( [ - f"", "# Task Handoff", "", "## Current Goal", @@ -1813,20 +1888,10 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None "", ] ) - path = handoff_path(task_dir) + path = task_dir / "HANDOFF.md" if not write_text_atomic(path, content): raise ClosureError("cannot write HANDOFF.md") return path - - -def handoff_path(task_dir: Path) -> Path: - """Return a safe handoff destination without following symbolic links.""" - path = task_dir / "HANDOFF.md" - if task_dir.is_symlink() or path_has_symlink(path, task_dir): - raise ClosureError("HANDOFF.md path crosses a symlink") - return path - - def write_closure_report(task_dir: Path, data: dict[str, Any]) -> Path: packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] results = [ @@ -2077,7 +2142,7 @@ def closure_next_action(data: dict[str, Any]) -> str: if not _research_change_fields(data): return "Record research changes with closure.py route before validate." if data.get("grill_completed") is not True: - return "Record the completed grill with closure.py grill --complete." + return "Record the completed grill with closure.py grill --complete --decision-ref ." if not str(data.get("intent") or "").strip(): return "Set intent with closure.py plan." if not _string_list(data.get("definition_of_done")): @@ -2275,6 +2340,64 @@ def _context_refs(task_dir: Path, data: dict[str, Any], root: Path) -> list[str] return _dedupe(refs) +def _validate_decision_ref( + task_dir: Path, + reference: str, +) -> tuple[str, list[str]]: + normalized = reference.replace("\\", "/").strip() + if ( + not normalized + or normalized.startswith("/") + or re.match(r"^[A-Za-z]:/", normalized) + or ".." in Path(normalized).parts + ): + return normalized, ["decision_ref must be repository- or task-relative"] + + root = get_repo_root(task_dir).resolve() + bases = [root] if normalized.startswith(".trellis/") else [task_dir, root] + resolved: Path | None = None + for base in bases: + candidate = (base / normalized).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file(): + resolved = candidate + break + if resolved is None: + return normalized, [f"decision_ref does not exist inside the repository: {normalized}"] + + try: + content = resolved.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return normalized, [f"decision_ref is not readable text: {normalized}"] + + headings = list(re.finditer(r"(?mi)^#{1,6}\s+(.+?)\s*$", content)) + present: set[str] = set() + empty: list[str] = [] + for index, match in enumerate(headings): + key = match.group(1).strip().casefold() + canonical = DECISION_SECTION_HEADINGS.get(key) + if canonical is None: + continue + present.add(key) + end = headings[index + 1].start() if index + 1 < len(headings) else len(content) + if not content[match.end():end].strip(): + empty.append(canonical) + missing = [ + canonical + for key, canonical in DECISION_SECTION_HEADINGS.items() + if key not in present + ] + errors: list[str] = [] + if missing: + errors.append("decision record is missing sections: " + ", ".join(missing)) + if empty: + errors.append("decision record has empty sections: " + ", ".join(empty)) + return normalized, errors + + def _read_jsonl_values(path: Path) -> list[dict[str, Any]]: if not path.is_file(): return [] diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index cc4f47d..401c5f5 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -6,9 +6,7 @@ import json import os import re -import shutil import subprocess -import tempfile from copy import deepcopy from datetime import datetime, timedelta, timezone from fnmatch import fnmatchcase @@ -18,7 +16,6 @@ from .closure import ( ClosureError, closure_constraints, - handoff_path, is_closure_task, now_utc, package_by_id, @@ -535,11 +532,6 @@ def prepare_dispatch_for_agent( if role and dispatch.get("role") != role: raise DispatchError("role_mismatch", "Agent role does not match validated dispatch") mode = str(task.get("closure_mode") or "lean") - if platform == "codex" and not hook_active and mode == "publication": - raise DispatchError( - "advisory_forbidden", - "publication cannot use advisory Codex native dispatch; use strict mode", - ) task_id = str(task.get("id") or task_dir.name) if hook_active: record_firewall_heartbeat( @@ -666,8 +658,6 @@ def accept_result_text( raise DispatchError("state_confirmation_failed", "cannot reload task state") validate_dispatch(task_dir, current, dispatch, repo_root) sanitized = _sanitized_result(dispatch, result, raw_text) - if dispatch.get("handoff_writer") and sanitized["status"] == "success": - handoff_path(task_dir) dispatch["status"] = "result_returned" dispatch["result_returned_at"] = now_utc() _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) @@ -992,326 +982,6 @@ def supersede_dispatch( return dispatch -def run_codex_strict( - task_dir: Path, - task: dict[str, Any], - repo_root: Path, - job_id: str, - *, - codex_bin: str = "codex", -) -> tuple[dict[str, Any] | None, list[str]]: - dispatch = load_dispatch(task_dir, job_id) - validate_dispatch(task_dir, task, dispatch, repo_root) - executable = shutil.which(codex_bin) - capability_errors = _codex_capability_errors(executable or codex_bin, repo_root) - mode = str(task.get("closure_mode") or "lean") - if capability_errors: - if mode in {"standard", "publication"}: - raise DispatchError("codex_strict_unavailable", capability_errors[0], capability_errors) - dispatch["status"] = "validated" - dispatch["advisory"] = True - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - return None, ["Codex strict capability is unavailable; using advisory native dispatch."] - - record_firewall_heartbeat( - repo_root, - "codex", - "strict", - task_id=str(task.get("id") or task_dir.name), - job_id=job_id, - ) - dispatch["status"] = "running" - dispatch["platform"] = "codex" - dispatch["execution_mode"] = "strict" - dispatch["started_at"] = now_utc() - _write_task_runtime_json(task_dir, dispatch_path(task_dir, job_id), dispatch) - runtime_dir = raw_trace_path(repo_root, task_dir, job_id).parent - runtime_dir.mkdir(parents=True, exist_ok=True) - try: - with tempfile.TemporaryDirectory(prefix="trellis-codex-strict-") as temporary: - workspace = Path(temporary) / "workspace" - _copy_strict_workspace(repo_root, workspace) - output_dir = workspace / ".trellis" / ".strict-output" - output_dir.mkdir(parents=True, exist_ok=True) - output_path = output_dir / f"{job_id}.last-message.json" - schema_path = workspace / ".trellis" / "hermes" / "schemas" / "result-envelope.schema.json" - before = _snapshot_workspace(workspace) - sandbox_mode = "workspace-write" if dispatch.get("role") in EXECUTION_ROLES else "read-only" - command = [ - executable or codex_bin, - "exec", - "--ephemeral", - "--ignore-user-config", - "--skip-git-repo-check", - "--sandbox", - sandbox_mode, - "--ask-for-approval", - "never", - "--output-schema", - str(schema_path), - "--json", - "-o", - str(output_path), - str(dispatch["body"]), - ] - completed = subprocess.run( - command, - cwd=workspace, - env=_strict_environment(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=3600, - check=False, - ) - after = _snapshot_workspace(workspace) - actual_changes = sorted( - path for path in set(before) | set(after) if before.get(path) != after.get(path) - ) - if completed.returncode != 0: - _mark_dispatch_terminal(task_dir, dispatch, "failed", "codex_exec_failed") - _apply_failed_execution_state(task_dir, task, dispatch, "Codex strict execution failed.") - raise DispatchError("codex_exec_failed", "Codex strict execution failed") - if not _changes_allowed(actual_changes, dispatch): - _mark_dispatch_terminal(task_dir, dispatch, "failed", "unauthorized_actual_changes") - _apply_failed_execution_state( - task_dir, - task, - dispatch, - "Codex strict changed files outside dispatch permissions.", - ) - raise DispatchError( - "unauthorized_actual_changes", - "Codex strict workspace changes violate dispatch permissions", - actual_changes, - ) - try: - raw_result = output_path.read_text(encoding="utf-8") - except OSError as exc: - raise DispatchError("missing_result", "Codex did not write the final result envelope") from exc - # Validate the complete envelope before any isolated code reaches the - # caller's workspace. A rejected result must leave source untouched, - # while still receiving the same recorded invalid-result outcome as - # a native dispatch. - try: - validate_result_envelope(task_dir, task, dispatch, raw_result, repo_root) - _validate_reported_changes(raw_result, actual_changes) - except DispatchError as exc: - _store_raw_trace(repo_root, task_dir, job_id, raw_result, "codex_strict_result") - _record_invalid_result(task_dir, task, dispatch, exc) - raise - sync_state = _prepare_strict_sync(repo_root, actual_changes, Path(temporary) / "sync-backup") - try: - _sync_strict_changes(workspace, repo_root, actual_changes, sync_state) - except Exception: - _restore_strict_sync(repo_root, sync_state) - raise - try: - accepted_result = accept_result_text(task_dir, task, repo_root, job_id, raw_result) - except Exception: - _restore_strict_sync(repo_root, sync_state) - raise - except DispatchError: - raise - except (OSError, subprocess.SubprocessError) as exc: - _store_raw_trace(repo_root, task_dir, job_id, str(exc), "codex_exec_error") - raise DispatchError("codex_exec_failed", "Codex strict execution failed") from exc - trace = json.dumps( - {"stdout": completed.stdout, "stderr": completed.stderr, "exit_code": completed.returncode}, - ensure_ascii=False, - ) - _store_raw_trace(repo_root, task_dir, job_id, trace, "codex_exec_trace") - return accepted_result, [] - - -def _copy_strict_workspace(repo_root: Path, workspace: Path) -> None: - def ignore(directory: str, names: list[str]) -> set[str]: - relative = Path(directory).resolve().relative_to(repo_root.resolve()) - ignored = { - name - for name in names - if name in { - ".aws", - ".git", - ".netrc", - ".npmrc", - ".pypirc", - ".ssh", - "__pycache__", - "coverage", - "credentials", - "dist", - "id_ed25519", - "id_rsa", - "node_modules", - } - or name.startswith(".env") - or name.endswith((".key", ".pem", ".p12", ".pfx")) - or any(marker in name.casefold() for marker in ("credential", "secret", "token")) - or _strict_workspace_file_contains_secret(Path(directory) / name) - } - if relative == Path(".trellis"): - ignored.add(".runtime") - return ignored - - shutil.copytree(repo_root, workspace, ignore=ignore, symlinks=True) - for path in workspace.rglob("*"): - if not path.is_symlink(): - continue - try: - path.resolve(strict=True).relative_to(workspace.resolve()) - except (OSError, ValueError) as exc: - raise DispatchError( - "strict_workspace_symlink", - f"strict workspace contains an external or broken symlink: {path.relative_to(workspace)}", - ) from exc - - -def _strict_workspace_file_contains_secret(path: Path) -> bool: - if not path.is_file(): - return False - try: - if path.stat().st_size > 1024 * 1024: - return False - content = path.read_text(encoding="utf-8", errors="replace") - except OSError: - return True - return any(pattern.search(content) for pattern in SECRET_PATTERNS) - - -def _snapshot_workspace(workspace: Path) -> dict[str, str]: - snapshot: dict[str, str] = {} - for path in workspace.rglob("*"): - relative = path.relative_to(workspace).as_posix() - if relative == ".trellis/.strict-output" or relative.startswith(".trellis/.strict-output/"): - continue - if path.is_symlink(): - snapshot[relative] = f"symlink:{os.readlink(path)}" - elif path.is_file(): - digest = hashlib.sha256() - try: - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as exc: - raise DispatchError("strict_snapshot_failed", f"cannot hash {relative}") from exc - snapshot[relative] = f"file:{digest.hexdigest()}" - return snapshot - - -def _strict_environment() -> dict[str, str]: - allowed = { - "PATH", - "HOME", - "USER", - "LOGNAME", - "LANG", - "LC_ALL", - "LC_CTYPE", - "TERM", - "SHELL", - "TMPDIR", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - "XDG_CACHE_HOME", - "CODEX_HOME", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - } - return {key: value for key, value in os.environ.items() if key in allowed} - - -def _validate_reported_changes(raw_result: str, actual_changes: list[str]) -> None: - try: - value = json.loads(raw_result) - except json.JSONDecodeError as exc: - raise DispatchError("invalid_json", "Codex strict result is not JSON") from exc - reported = value.get("changed_files") if isinstance(value, dict) else None - if not isinstance(reported, list) or any(not isinstance(item, str) for item in reported): - raise DispatchError("invalid_result_field", "changed_files must describe actual strict changes") - normalized = sorted(dict.fromkeys(item.replace("\\", "/").removeprefix("./") for item in reported)) - if normalized != actual_changes: - raise DispatchError( - "changed_files_mismatch", - "reported changed_files do not match the strict workspace diff", - [f"reported={normalized}", f"actual={actual_changes}"], - ) - - -def _prepare_strict_sync( - repo_root: Path, - changed_files: list[str], - backup_root: Path, -) -> dict[str, Any]: - backups: dict[str, Path | None] = {} - for relative in changed_files: - target = repo_root / relative - if target.is_symlink(): - raise DispatchError("strict_workspace_symlink", f"cannot sync symlinked path: {relative}") - if target.exists(): - if target.is_dir(): - raise DispatchError("unauthorized_actual_changes", f"strict sync cannot replace directory: {relative}") - backup = backup_root / relative - backup.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, backup) - backups[relative] = backup - else: - backups[relative] = None - return {"backups": backups, "created_dirs": []} - - -def _ensure_sync_parent(target: Path, repo_root: Path, state: dict[str, Any]) -> None: - missing: list[Path] = [] - parent = target.parent - while parent != repo_root and not parent.exists(): - missing.append(parent) - parent = parent.parent - for directory in reversed(missing): - directory.mkdir() - state["created_dirs"].append(directory) - - -def _sync_strict_changes( - workspace: Path, - repo_root: Path, - changed_files: list[str], - state: dict[str, Any], -) -> None: - for relative in changed_files: - source = workspace / relative - target = repo_root / relative - if source.is_symlink() or target.is_symlink(): - raise DispatchError("strict_workspace_symlink", f"cannot sync symlinked path: {relative}") - if source.is_file(): - _ensure_sync_parent(target, repo_root, state) - shutil.copy2(source, target) - elif target.exists(): - if target.is_dir(): - raise DispatchError("unauthorized_actual_changes", f"strict sync cannot delete directory: {relative}") - target.unlink() - - -def _restore_strict_sync(repo_root: Path, state: dict[str, Any]) -> None: - backups = state.get("backups") - if not isinstance(backups, dict): - return - for relative, backup in backups.items(): - if not isinstance(relative, str): - continue - target = repo_root / relative - if isinstance(backup, Path): - _ensure_sync_parent(target, repo_root, state) - shutil.copy2(backup, target) - elif target.is_file(): - target.unlink() - for directory in reversed(state.get("created_dirs", [])): - try: - directory.rmdir() - except OSError: - pass - - def result_json_schema() -> dict[str, Any]: string_array = {"type": "array", "items": {"type": "string"}} return { diff --git a/packages/cli/src/templates/trellis/scripts/common/firewall.py b/packages/cli/src/templates/trellis/scripts/common/firewall.py index 1ec3aa5..7d5b4a4 100644 --- a/packages/cli/src/templates/trellis/scripts/common/firewall.py +++ b/packages/cli/src/templates/trellis/scripts/common/firewall.py @@ -38,7 +38,7 @@ def record_firewall_heartbeat( job_id: str | None = None, session_id: str | None = None, ) -> bool: - if platform not in SUPPORTED_PLATFORMS or mechanism not in {"hooks", "strict"}: + if platform not in SUPPORTED_PLATFORMS or mechanism != "hooks": return False if mechanism == "hooks" and hooks_disabled(): return False @@ -85,7 +85,7 @@ def firewall_health( continue record_platform = value.get("platform") record_mechanism = value.get("mechanism") - if record_platform not in SUPPORTED_PLATFORMS or record_mechanism not in {"hooks", "strict"}: + if record_platform not in SUPPORTED_PLATFORMS or record_mechanism != "hooks": continue age = (now - timestamp).total_seconds() if timestamp is not None else None if timestamp is None or age is None or age < -5 or age > _heartbeat_ttl(): @@ -124,7 +124,6 @@ def firewall_health( "hard_gate": bool(deduped), "advisory_signals": { "hooks_env": os.environ.get("TRELLIS_HOOKS_ACTIVE") == "1", - "codex_strict_env": os.environ.get("TRELLIS_CODEX_STRICT") == "1", }, } @@ -148,21 +147,11 @@ def closure_mode_gate( session_id=session_id, ) active = health["active"] - if mode == "lean": - if not active: - return [], [ - "Lean context firewall is advisory because no fresh hook or strict heartbeat is active." - ] - return [], [] if active: return [], [] - if mode == "publication": - return [ - "publication requires an active Claude hook or Codex strict context-firewall heartbeat" - ], [] - return [ - "standard requires an active hook heartbeat or Codex strict execution" - ], [] + return [], [ + "No active context-firewall hook was detected; use the validated dispatch and Result Envelope protocol." + ] def _parse_timestamp(value: Any) -> datetime | None: diff --git a/packages/cli/src/templates/trellis/scripts/common/types.py b/packages/cli/src/templates/trellis/scripts/common/types.py index e88139f..95e6b22 100644 --- a/packages/cli/src/templates/trellis/scripts/common/types.py +++ b/packages/cli/src/templates/trellis/scripts/common/types.py @@ -60,6 +60,7 @@ class TaskData(TypedDict, total=False): research_route: str research_change_fields: list[str] grill_completed: bool + decision_ref: str | None constraints: dict work_packages: list[dict] current_work_package: str | None diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index 0811875..addc9e9 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -24,7 +24,6 @@ load_sanitized_result, prepare_dispatch_for_agent, result_json_schema, - run_codex_strict, sanitized_summary, supersede_dispatch, validate_dispatch, @@ -71,8 +70,8 @@ def build_parser() -> argparse.ArgumentParser: _task_arg(run) run.add_argument("--job-id", required=True) run.add_argument("--platform", choices=["claude", "codex"], required=True) - run.add_argument("--mode", choices=["native", "strict"], default="native") - run.add_argument("--codex-bin", default="codex") + run.add_argument("--mode", choices=["native", "strict"], default="native", + help="strict is retained as a compatibility alias for native") apply = subparsers.add_parser("apply") _task_arg(apply) @@ -169,16 +168,11 @@ def main(argv: list[str] | None = None) -> int: print(dispatch["body"] if args.prompt else json.dumps(dispatch, ensure_ascii=False, indent=2)) return 0 if args.command == "run": - if args.platform == "codex" and args.mode == "strict": - result, warnings = run_codex_strict(task_dir, task, root, args.job_id, codex_bin=args.codex_bin) - for warning in warnings: - print(f"warning: {warning}", file=sys.stderr) - if result is None: - dispatch = load_dispatch(task_dir, args.job_id) - _print({"job_id": args.job_id, "status": "advisory", "prompt": dispatch["body"]}) - else: - _print(sanitized_summary(task_dir, args.job_id)) - return 0 + if args.mode == "strict": + print( + "warning: strict dispatch is retired; using the current project workspace with native dispatch", + file=sys.stderr, + ) dispatch = prepare_dispatch_for_agent( task_dir, task, diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runner.py b/packages/cli/src/templates/trellis/scripts/hermes/runner.py index a47ec01..113cb44 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runner.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runner.py @@ -4,10 +4,7 @@ import argparse import hashlib import json -import os import re -import shlex -import shutil import subprocess import sys import time @@ -17,15 +14,12 @@ from runtime import ( add_seconds, append_record, - container_image_error, find_task_card, format_timestamp, make_record_id, now_utc, normalize_path, parse_interval_seconds, - parse_bool, - parse_container_runtime_command, parse_timestamp, read_jsonl, record_path, @@ -35,6 +29,7 @@ run_manifest_path, task_card_error, validate_experiment_config, + validate_data_preflight, validate_run_manifest_records, ) @@ -178,30 +173,6 @@ def env_summary() -> dict[str, str]: } -def minimal_subprocess_env() -> dict[str, str]: - allowed_names = { - "HOME", - "LANG", - "LC_ALL", - "PATH", - "PYTHONPATH", - "SYSTEMROOT", - "TMPDIR", - "TEMP", - "TMP", - "USERPROFILE", - "VIRTUAL_ENV", - } - env: dict[str, str] = {} - for name in allowed_names: - value = os.environ.get(name) - if value: - env[name] = value - if "PATH" not in env: - env["PATH"] = os.defpath - return env - - def command_display(command: list[str]) -> str: return " ".join(command) @@ -224,61 +195,6 @@ def command_allowed(command: list[str], allowed_commands: Any) -> bool: return False -def sandbox_execution_settings(experiment: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: - sandbox = experiment.get("sandbox", {"mode": "none", "required": False}) - if not isinstance(sandbox, dict): - return {}, ["sandbox must be a mapping"] - mode = sandbox.get("mode", "none") - required = parse_bool(sandbox.get("required", False)) - if required is None: - return {}, ["sandbox.required must be true or false"] - if mode not in {"none", "container", "external"}: - return {}, ["sandbox.mode must be one of none, container, external"] - if required and mode == "none": - return {}, ["sandbox.required=true cannot use mode=none"] - settings: dict[str, Any] = {"mode": mode, "required": required} - if mode == "none": - return settings, [] - - command = sandbox.get("command") - if not isinstance(command, str) or not command.strip(): - command = "docker" if mode == "container" else "" - if not command: - return settings, ["sandbox mode external requires sandbox.command"] - if mode == "container": - runtime_command, command_error = parse_container_runtime_command(command) - if command_error is not None: - return settings, [command_error] - else: - try: - runtime_command = shlex.split(command) - except ValueError as exc: - return settings, [f"sandbox.command is invalid: {exc}"] - if not runtime_command: - return settings, [f"sandbox mode {mode} requires sandbox.command"] - executable = runtime_command[0] - if shutil.which(executable) is None: - return settings, [f"sandbox mode {mode} not available: command {executable} not found"] - settings["runtime_command"] = runtime_command - if mode == "container": - image = sandbox.get("image") - image_error = container_image_error(image) - if image_error is not None: - return settings, [image_error] - if not isinstance(image, str): - return settings, ["sandbox mode container requires sandbox.image"] - settings["image"] = image.strip() - return settings, [] - return settings, [ - f"sandbox mode {mode} availability was checked, but this runner does not provide an OS sandbox" - ] - - -def sandbox_gate_errors(experiment: dict[str, Any]) -> list[str]: - _, errors = sandbox_execution_settings(experiment) - return errors - - def redact_secret_text(value: str) -> str: secret_name_pattern = re.compile( r"\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*\b" @@ -290,67 +206,6 @@ def manifest_command(command: list[str]) -> list[str]: return [redact_secret_text(part) for part in command] -def container_workdir(root: Path, cwd: Path) -> str: - relative = relative_to_root(root, cwd) - if relative in {"", "."}: - return "/workspace" - return f"/workspace/{relative}" - - -def containerized_command( - command: list[str], - root: Path, - cwd: Path, - sandbox: dict[str, Any], -) -> list[str]: - runtime_command = sandbox.get("runtime_command") - image = sandbox.get("image") - if not isinstance(runtime_command, list) or not all( - isinstance(part, str) and part for part in runtime_command - ): - raise ValueError("container runtime command is not configured") - if not isinstance(image, str) or not image.strip(): - raise ValueError("container image is not configured") - return [ - *runtime_command, - "run", - "--rm", - "-v", - f"{root.resolve(strict=False).as_posix()}:/workspace", - "-w", - container_workdir(root, cwd), - image, - *command, - ] - - -def command_for_execution( - command: list[str], - root: Path, - cwd: Path, - sandbox: dict[str, Any], -) -> list[str]: - if sandbox.get("mode") == "container": - return containerized_command(command, root, cwd, sandbox) - return command - - -def manifest_sandbox(sandbox: dict[str, Any]) -> dict[str, str] | None: - mode = sandbox.get("mode") - if mode == "none": - return None - entry: dict[str, str] = {"mode": str(mode)} - image = sandbox.get("image") - if isinstance(image, str) and image.strip(): - entry["image"] = redact_secret_text(image.strip()) - runtime_command = sandbox.get("runtime_command") - if isinstance(runtime_command, list) and runtime_command: - runtime = runtime_command[0] - if isinstance(runtime, str) and runtime.strip(): - entry["runtime"] = redact_secret_text(Path(runtime).name) - return entry - - def resolve_repo_path(root: Path, base: Path, value: str, label: str) -> tuple[Path | None, str | None]: raw = value.strip() if not raw: @@ -406,11 +261,6 @@ def run_command(args: argparse.Namespace) -> int: for error in experiment_load_errors: print(error, file=sys.stderr) return 1 - sandbox, sandbox_errors = sandbox_execution_settings(experiment) - if sandbox_errors: - for error in sandbox_errors: - print(error, file=sys.stderr) - return 1 if not command_allowed(command, experiment.get("allowed_commands")): print( f"command not allowed by experiment allowed_commands: {command_display(command)}", @@ -424,13 +274,6 @@ def run_command(args: argparse.Namespace) -> int: print("invalid --heartbeat-interval; use values like 30s, 5m, or 1h", file=sys.stderr) return 2 - hermes_dir = worker_path.parent - runs_dir = hermes_dir / "runs" - run_id = make_record_id("run", job_id) - run_dir = runs_dir / run_id - run_dir.mkdir(parents=True, exist_ok=True) - stdout_path = run_dir / "stdout.log" - stderr_path = run_dir / "stderr.log" if args.cwd is None: cwd = root else: @@ -449,12 +292,37 @@ def run_command(args: argparse.Namespace) -> int: for error in path_errors: print(error, file=sys.stderr) return 2 - try: - execution_command = command_for_execution(command, root, cwd, sandbox) - except ValueError as exc: - print(str(exc), file=sys.stderr) + preflight_paths, preflight_errors = validate_data_preflight(exp_path, experiment) + if preflight_errors: + for error in preflight_errors: + print(error, file=sys.stderr) return 1 - + declared_inputs = { + resolved + for value in inputs + for resolved, error in [resolve_repo_path(root, cwd, value, "input")] + if error is None and resolved is not None + } + missing_preflight_inputs = [ + path.relative_to(root).as_posix() + for path in preflight_paths + if path not in declared_inputs + ] + if missing_preflight_inputs: + print( + "data_preflight files must be declared with --input: " + + ", ".join(missing_preflight_inputs), + file=sys.stderr, + ) + return 1 + input_entries = [path_entry(root, cwd, value, "input") for value in inputs] + hermes_dir = worker_path.parent + runs_dir = hermes_dir / "runs" + run_id = make_record_id("run", job_id) + run_dir = runs_dir / run_id + run_dir.mkdir(parents=True, exist_ok=True) + stdout_path = run_dir / "stdout.log" + stderr_path = run_dir / "stderr.log" started_at = now_utc() append_record(worker_path, checkpoint_record(args)) append_record(worker_path, heartbeat_record(args.job_id, args.checkpoint, args.summary, interval_seconds)) @@ -464,11 +332,10 @@ def run_command(args: argparse.Namespace) -> int: with stdout_path.open("wb") as stdout_handle, stderr_path.open("wb") as stderr_handle: try: process = subprocess.Popen( - execution_command, + command, cwd=str(cwd), stdout=stdout_handle, stderr=stderr_handle, - env=minimal_subprocess_env(), ) except OSError as exc: launch_error = exc @@ -499,7 +366,7 @@ def run_command(args: argparse.Namespace) -> int: "command": manifest_command(command), "cwd": relative_to_root(root, cwd), "env_summary": env_summary(), - "inputs": [path_entry(root, cwd, value, "input") for value in inputs], + "inputs": input_entries, "outputs": output_entries + log_entries, "exit_code": exit_code, "started_at": started_at, @@ -507,9 +374,6 @@ def run_command(args: argparse.Namespace) -> int: "checkpoint": args.checkpoint, "resume_from": args.resume_from, } - sandbox_record = manifest_sandbox(sandbox) - if sandbox_record is not None: - manifest_record["sandbox"] = sandbox_record if launch_error is not None: manifest_record["error"] = f"cannot start command: {launch_error}" manifest_path = run_manifest_path(root, task) diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index 238f4e5..e9e4d1a 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -3,7 +3,6 @@ import json import hashlib import re -import shlex import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -97,10 +96,12 @@ "allowed_commands", "artifact_dir", ] +DATA_PREFLIGHT_CHANGE_FIELDS = {"dataset", "split", "preprocessing"} +DATA_PREFLIGHT_CHECKS = ("schema", "missing", "duplicates", "split_leakage") +DATA_PREFLIGHT_STATES = {"checked", "not_applicable"} SECRET_ENV_NAME_PATTERN = re.compile( r"\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*\b" ) -CONTAINER_IMAGE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,255}$") REQUIRED_FIELDS = { "task_card": [ @@ -454,32 +455,6 @@ def is_nonempty_string(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) -def container_image_error(image: Any) -> str | None: - if not isinstance(image, str) or not image.strip(): - return "sandbox mode container requires sandbox.image" - normalized = image.strip() - if normalized.startswith("-"): - return "sandbox.image must be a container image reference, not a docker flag" - if any(char.isspace() for char in normalized): - return "sandbox.image must not contain whitespace" - if CONTAINER_IMAGE_PATTERN.fullmatch(normalized) is None: - return "sandbox.image contains unsupported characters" - return None - - -def parse_container_runtime_command(command: Any) -> tuple[list[str], str | None]: - text = command if isinstance(command, str) and command.strip() else "docker" - try: - runtime_command = shlex.split(text) - except ValueError as exc: - return [], f"sandbox.command is invalid: {exc}" - if len(runtime_command) != 1: - return [], "sandbox.command for container mode must be docker without extra arguments" - if Path(runtime_command[0]).name != "docker": - return [], "sandbox mode container requires docker as sandbox.command" - return runtime_command, None - - def validate_optional_string_list( record: dict[str, Any], field: str, @@ -1238,7 +1213,12 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: errors.append( f"line {entry.line_number}: missing checkpoint for job_id {job_id}" ) - if card and card.get("role") == "coder" and is_coder_review_handoff(value): + if ( + card + and card.get("role") == "coder" + and card.get("profile") != "configuration" + and is_coder_review_handoff(value) + ): if not has_related_record( records, task_cards, @@ -1816,32 +1796,131 @@ def validate_experiment_config(path: Path) -> list[str]: for command in parsed["allowed_commands"] ): return [f"{path}: allowed_commands must be a non-empty list of strings"] - sandbox = parsed.get("sandbox", {"mode": "none", "required": False}) - if not isinstance(sandbox, dict): - return [f"{path}: sandbox must be a mapping"] - sandbox_mode = sandbox.get("mode", "none") - if sandbox_mode not in {"none", "container", "external"}: - return [f"{path}: sandbox.mode must be one of none, container, external"] - sandbox_required = parse_bool(sandbox.get("required", False)) - if sandbox_required is None: - return [f"{path}: sandbox.required must be true or false"] - if sandbox_required and sandbox_mode == "none": - return [f"{path}: sandbox.required=true cannot use mode=none"] - if sandbox_mode == "container": - _, command_error = parse_container_runtime_command(sandbox.get("command")) - if command_error is not None: - return [f"{path}: {command_error}"] - image_error = container_image_error(sandbox.get("image")) - if image_error is not None: - return [f"{path}: {image_error}"] - if sandbox_mode == "external": - command = sandbox.get("command") - if command is not None and not is_nonempty_string(command): - return [f"{path}: sandbox.command must be a non-empty string when present"] if not isinstance(parsed.get("artifact_dir"), str) or not parsed["artifact_dir"].strip(): return [f"{path}: artifact_dir must be a non-empty string"] - return [] + _, preflight_errors = validate_data_preflight(path, parsed) + return preflight_errors + + +def _repo_file(root: Path, value: Any, label: str) -> tuple[Path | None, str | None]: + if not isinstance(value, str) or not value.strip(): + return None, f"{label} must be a non-empty repository-relative path" + normalized = value.strip().replace("\\", "/") + if normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized) or ".." in Path(normalized).parts: + return None, f"{label} must stay inside the repository" + candidate = (root / normalized).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + return None, f"{label} must stay inside the repository" + if not candidate.is_file() or has_symlink_component(candidate): + return None, f"{label} does not exist as a regular repository file: {normalized}" + return candidate, None + + +def _task_requires_data_preflight(path: Path) -> bool: + task_path = path.parent.parent / "task.json" + try: + task = json.loads(task_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return False + if not isinstance(task, dict) or task.get("research_route") != "exploration": + return False + fields = task.get("research_change_fields") + return isinstance(fields, list) and bool( + DATA_PREFLIGHT_CHANGE_FIELDS.intersection( + item for item in fields if isinstance(item, str) + ) + ) + + +def _load_preflight_checks(path: Path) -> tuple[dict[str, Any], str | None]: + try: + content = path.read_text(encoding="utf-8") + parsed = json.loads(content) if path.suffix.casefold() == ".json" else parse_simple_yaml(content) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + return {}, f"data_preflight.checks_ref cannot be parsed: {exc}" + if not isinstance(parsed, dict): + return {}, "data_preflight.checks_ref must contain a mapping" + checks = parsed.get("checks", parsed) + if not isinstance(checks, dict): + return {}, "data_preflight.checks_ref checks must be a mapping" + return checks, None + + +def validate_data_preflight( + path: Path, + experiment: dict[str, Any], +) -> tuple[list[Path], list[str]]: + required = _task_requires_data_preflight(path) + preflight = experiment.get("data_preflight") + if preflight is None: + if required: + return [], [f"{path}: data_preflight is required for exploration changes to dataset, split, or preprocessing"] + return [], [] + if not isinstance(preflight, dict): + return [], [f"{path}: data_preflight must be a mapping"] + + errors: list[str] = [] + for field in ("source", "version"): + if not isinstance(preflight.get(field), str) or not str(preflight[field]).strip(): + errors.append(f"{path}: data_preflight.{field} must be a non-empty string") + + input_manifest = preflight.get("input_manifest") + data_path = preflight.get("data_path") + selected = [ + (name, value) + for name, value in (("input_manifest", input_manifest), ("data_path", data_path)) + if isinstance(value, str) and value.strip() + ] + if len(selected) != 1: + errors.append(f"{path}: data_preflight requires exactly one of input_manifest or data_path") + + root = repo_root(path) + tracked_paths: list[Path] = [] + data_file: Path | None = None + if len(selected) == 1: + field, value = selected[0] + data_file, error = _repo_file(root, value, f"data_preflight.{field}") + if error: + errors.append(f"{path}: {error}") + elif data_file is not None: + tracked_paths.append(data_file) + + expected_hash = preflight.get("hash") + if not isinstance(expected_hash, str) or not is_valid_sha256(expected_hash): + errors.append(f"{path}: data_preflight.hash must be sha256:<64 lowercase hex>") + elif data_file is not None: + actual_hash = sha256_file(data_file) + if actual_hash != expected_hash: + errors.append( + f"{path}: data_preflight hash mismatch for {data_file.relative_to(root).as_posix()}" + ) + + checks_path, checks_error = _repo_file( + root, + preflight.get("checks_ref"), + "data_preflight.checks_ref", + ) + if checks_error: + errors.append(f"{path}: {checks_error}") + elif checks_path is not None: + tracked_paths.append(checks_path) + checks, parse_error = _load_preflight_checks(checks_path) + if parse_error: + errors.append(f"{path}: {parse_error}") + else: + for check in DATA_PREFLIGHT_CHECKS: + raw = checks.get(check) + if isinstance(raw, dict): + raw = raw.get("status") + state = str(raw).strip().casefold() if raw is not None else "" + if state not in DATA_PREFLIGHT_STATES: + errors.append( + f"{path}: data_preflight check {check} must be checked or not_applicable" + ) + return tracked_paths, errors def parse_bool(value: Any) -> bool | None: @@ -1897,9 +1976,6 @@ def write_experiment_skeleton(path: Path, task: str) -> bool: ' shell: "bash"', "allowed_commands:", ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: false", f'artifact_dir: ".trellis/tasks/{task}/hermes/runs"', "", ] diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index bd5dbe1..1d8c386 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -24,7 +24,6 @@ from __future__ import annotations import argparse -import re import subprocess import sys @@ -215,7 +214,7 @@ def cmd_start(args: argparse.Namespace) -> int: if is_closure_task(task_data): phase = task_data.get("hermes_phase") if phase == "planning": - errors, _ = validate_closure(task_data) + errors, _ = validate_closure(task_data, full_path) detail = "; ".join(errors[:3]) if errors else "plan has not been validated" print(colored( f"Error: Closure task is not ready: {detail}", @@ -306,27 +305,20 @@ def cmd_finish(args: argparse.Namespace) -> int: if task_json_path.is_file(): task_data = read_json(task_json_path) if isinstance(task_data, dict): - from common.closure import is_closure_task + from common.closure import ClosureError, is_closure_task, write_handoff if is_closure_task(task_data) and task_data.get("closure_state") != "closed": - revision = task_data.get("hermes_revision") - handoff_path = task_json_path.parent / "HANDOFF.md" - fresh_handoff = ( - isinstance(revision, int) - and not isinstance(revision, bool) - and handoff_path.is_file() - and not handoff_path.is_symlink() - and re.search( - rf"", - handoff_path.read_text(encoding="utf-8", errors="replace")[:512], - ) is not None - ) - if not fresh_handoff: + try: + handoff_path = write_handoff(task_json_path.parent, task_data, repo_root) print(colored( - "Open Hermes task requires a current HANDOFF.md from the dedicated handoff subagent before finish.", - Colors.RED, + f"✓ Handoff updated: {handoff_path.relative_to(repo_root)}", + Colors.GREEN, + )) + except (ClosureError, OSError): + print(colored( + "Warning: could not update HANDOFF.md; task state remains in task.json.", + Colors.YELLOW, )) - return 1 cleared = clear_active_task(repo_root) diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 8ba9f09..2582655 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -89,13 +89,15 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] work package 与 subtask 的边界:work package 属于同一 task 内部,适合顺序推进和共享上下文;subtask 用于可独立交付、独立审查或并行工作的任务。`task.json` 是当前状态唯一事实源,`hermes/task-events.jsonl` 只保存重要状态历史。 +科研协作以证据和事实为先,不以迎合用户结论为目标。主代理应明确重要假设、不确定性和有证据的异议,科研取舍由用户批准。普通可逆任务直接执行;有限不确定性先声明假设再执行;只有改变研究问题、协议或主张边界的高风险研究变化才暂停讨论。证据冲突或关键假设失效时进入 `blocked`(阻塞),通过人工批准的 `amend`(修改)更新协议后再继续。提示词不能代替任务和实验中的结构化记录,也不能因此让每个任务都经过 grill。 + ```bash # 规划与验证 python3 ./.trellis/scripts/closure.py plan --task \ --intent "可观察目标" --done-when "完成条件" # 研究协议分流:探索任务需记录变化字段和已完成 grill python3 ./.trellis/scripts/closure.py route --task --route exploration --research-change hypothesis -python3 ./.trellis/scripts/closure.py grill --task --complete +python3 ./.trellis/scripts/closure.py grill --task --complete --decision-ref design.md python3 ./.trellis/scripts/closure.py validate --task # 每次只处理当前 package @@ -108,10 +110,10 @@ python3 ./.trellis/scripts/closure.py audit --task python3 ./.trellis/scripts/closure.py repair --task python3 ./.trellis/scripts/closure.py close --task -# 正式角色派发:Claude 只传 job_id;Codex strict 由 CLI 执行 +# 正式角色派发:Claude 只传 job_id;Codex 使用同一紧凑派发协议 python3 ./.trellis/scripts/hermes/dispatch.py create --task --role --profile --objective "任务" python3 ./.trellis/scripts/hermes/dispatch.py validate --task --job-id -python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict +python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex ``` 每轮 hook 优先注入约 500–1000 字符的 Task Capsule,只包含 intent、范围、当前 package、done_when、next action、blockers 和最多 3 个相关引用。完整 PRD、报告、事件、ledger、历史任务和非当前 spec 均保留,但只在需要时读取。Claude Code、Codex 与 OpenCode 使用相同 capsule 语义;没有每轮 hook 的平台需要显式运行 `closure.py capsule`。 @@ -122,7 +124,9 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - 科研任务按研究协议是否改变分流,不按任务大小。`delivery`(确定性交付)不改变研究协议,等价重构和性能优化留在这里;`execution`(已冻结协议的实验执行)只运行种子或预先批准的参数搜索;`exploration`(探索性研究)记录 hypothesis、model_architecture、dataset、split、preprocessing、objective/loss、metric_definition、baseline、claim_scope 中实际变化的字段,并在 validate 前记录已完成的 grill。新假设或模型功能架构变化属于高风险探索,必须创建独立探索任务(可挂同一父项目),不能伪装成普通 work package。 -Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex native 仅为 advisory,strict 才机械执行和净化结果。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 +新完成的高风险 grill 必须用 `decision_ref`(决策记录引用)指向仓库内或任务内的 `prd.md`(需求文档)、`design.md`(设计文档)等记录。该记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件);事件和 Task Capsule 只保存引用,不注入正文。旧任务只有 `grill_completed=true`(已完成讨论)而没有引用时继续兼容并提示警告。涉及 dataset、split 或 preprocessing 的 exploration 还必须在任务级 `experiment.yaml`(实验配置)填写 `data_preflight`(数据预检);其他 exploration、delivery 和 execution 不增加该步骤。 + +Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 @@ -213,9 +217,10 @@ Phase 3: Finish → 验证、更新 spec、提交代码、最后收尾 [workflow-state:no_task] -No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task. +No active task. First analyze every actionable request: intent, scope, observable completion, research-protocol impact, risk, and the smallest useful 1-4 package split. Then ask for task-creation consent before creating any Trellis task. Simple conversation / small task: ask only whether this turn should create a Trellis task. If the user says no, skip Trellis for this session. Complex task: ask the user if you can create a Trellis task and enter the planning phase. If the user says no, explain, clarify scope, or suggest a smaller split. +Do not load a planning skill by default. Use brainstorm only for unresolved requirements and grill-me only for a material unresolved research or scope decision. 当用户用自然语言提出 Claude Code 工作流改动时,先引导他们进入 `task.py create`;不要要求固定命令短语。 [/workflow-state:no_task] @@ -226,6 +231,8 @@ Complex task: ask the user if you can create a Trellis task and enter the planni 建议用户主动理解和调用的技能只有 `grill-me`(聚焦讨论)和 `update-spec`(更新规范)。前者用于未解决的研究设计、架构或范围决策,后者用于把经过验证的项目知识写入规范;即使用户不显式调用,主代理也必须在相应条件出现时自动触发这些过程。为保持旧项目兼容和自动路由,其他规划、规范读取、验证、测试驱动、根因分析和架构审查技能仍会随平台安装,但不要求用户记忆其名称。 +技能不按固定数量加载,也不构成一条每次都要执行的流水线。新任务由主代理完成轻量分析和 1–4 个结果型工作包;已有任务的普通补充继续当前工作包,只有范围或研究协议变化才 amend 并重新 validate。主代理负责触发和整合,具体执行按权限下发:`before-dev` 给 coder,测试/实验给 runner,质量/证据/统计/closure 给 reviewer,根因或方法分析给 planner,资料检索给 researcher,规范与 handoff 写入给 `coder:configuration`。 + ### Phase 1: Plan - 1.0 Create task `[required · once]`(只有获得 task-creation consent 后才能执行) - 1.1 Requirement exploration `[required · repeatable]`(`prd.md`;复杂任务还需要 `design.md` + `implement.md`) @@ -244,7 +251,8 @@ Multi-deliverable scope: consider a parent task plus independently verifiable ch Sub-agent mode: curate `implement.jsonl` and `check.jsonl` as spec/research manifests before start. Planning order: `task.py create` → evidence-based main-agent analysis → optional `trellis-brainstorm` / `trellis-grill-me` only for an unresolved material decision → development strategy decision. -For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. An exploration route records a focused completed grill for its changed research decision. +Use three intervention levels: execute ordinary reversible work directly; state material assumptions and proceed under limited uncertainty; pause only for a high-risk research change, then record the approved decision. Evidence conflict or a failed critical assumption means blocked plus an approved amendment. +For a Lean Research Closure task, intent, definition of done, route, and a validated work-package plan are sufficient to enter ready. A new completed high-risk exploration grill also records a repository- or task-relative decision_ref; prompts never replace this record, and ordinary tasks do not acquire a grill gate. When a grill is running, do not enter development strategy decisions, do not create or complete `design.md` / `implement.md`, and do not run `task.py start` until that decision is resolved. Do not enter development strategy decisions until `prd.md` has been tightened through repository-first clarification and one-question-at-a-time follow-up. Before `task.py start`, record the development strategy decisions in the task documents. Complex tasks should store them in `implement.md`: development mode (current session / subagent), branch vs worktree, default flow vs TDD, plus a single `A.` / `B.` / `C.` style strategy block that records the task-level selection for `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, `trellis-improve-codebase-architecture`, and `trellis-merge-review`. New tasks must stamp that block with `Review-gate contract: explicit-selection-v1`. Use `Optional review gates status: pending` only while the choice is still open; before `task.py start`, replace it with `Optional review gates status: configured` plus explicit `Enabled optional review gates:` and `Disabled optional review gates:` lists. If the user does not select any optional gate, still record all five in the disabled list and keep `trellis-check` fixed outside the optional set. Only tasks that entirely lack `Review-gate contract: explicit-selection-v1` count as legacy tasks and preserve the old behavior; if the marker exists but the configured enabled/disabled lists are missing, planning is incomplete and the task must not start. `trellis-improve-codebase-architecture` deep-review requires `trellis-code-architecture-review`; do not record or accept deep-review without that prerequisite gate. If the strategy is `subagent + worktree`, pin the shared path to `./.trellis/trellis-worktrees/` and require every code-development subagent to use it. If the strategy is TDD, record `trellis-tdd` as the reference flow. Record whether to run pre-development architecture guidance in that same strategy block. If guidance is enabled, record `架构审查:enabled` in `implement.md`, dispatch `trellis-improve-codebase-architecture` with `架构审查模式: guidance` before `task.py start`, and append its output to `design.md`, but do NOT implicitly enable `trellis-improve-codebase-architecture` deep-review; that gate still requires explicit selection in the same task-level review-gate set. @@ -258,7 +266,7 @@ Before `task.py start`, record the development strategy decisions in the task do [workflow-state:planning-inline] Hermes closure: use the injected Task Capsule first. Run `closure.py plan` and `closure.py validate`; do not treat commands or file operations as packages. -Load `trellis-brainstorm`; stay in planning. +Analyze every new task, but load `trellis-brainstorm` only for unresolved requirements and `trellis-grill-me` only for a material unresolved research decision. Lightweight: `prd.md` can be enough. Complex: finish `prd.md`, `design.md`, and `implement.md`; ask for review before `task.py start`. Multi-deliverable scope: consider a parent task plus independently verifiable child tasks; dependencies must be written in child artifacts, not implied by tree position. Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-before-dev`. @@ -280,14 +288,15 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex native is advisory and publication requires strict mode. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> archive or commit as needed -> merge if needed -> optional `trellis-merge-review` -> build/test -> `/trellis:finish-work`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, or blocking. At closure, reviewer checks closure and runner performs final validation/archive actions. +Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. -Main-session default: dispatch implement/check sub-agents. Sub-agent self-exemption: if already running as `trellis-implement`, do NOT spawn another `trellis-implement` or `trellis-check`; if already running as `trellis-check`, do NOT spawn another `trellis-check` or `trellis-implement`. Dispatch is main session only. +Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. Dispatch prompt starts with `Active task: `. Read context: jsonl entries -> `prd.md` -> `design.md if present` -> `implement.md if present`. Claude review gates are read-only gates. They report PASS / FAIL, blocking issues, and suggested next actions; they do not modify code directly. -If a Claude review gate fails, the main agent repairs the code, then re-runs the same gate before advancing. +If a Claude code review gate fails, the main agent dispatches a bounded coder repair, then re-runs the same gate before advancing. For any enabled `trellis-spec-review`, `trellis-code-review`, `trellis-code-architecture-review`, and `trellis-merge-review`, count repeated failures per gate and per task. If the same gate blocks the same task more than 3 times in a row, the main agent must briefly report that status to the user, re-check whether the requirements have drifted, and ask whether to skip the current review gate. If the chosen strategy is `subagent + worktree`, all code-development subagents must use the same `./.trellis/trellis-worktrees/` path. If the chosen strategy is TDD, align implementation and review expectations to `trellis-tdd`. diff --git a/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts index fb38e3d..4e352b2 100644 --- a/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts +++ b/packages/cli/test/commands/blank-bootstrap-closure.integration.test.ts @@ -42,8 +42,41 @@ describe("empty repository bootstrap closure", () => { fs.writeFileSync(path.join(project, "RESEARCH_PLAN.md"), "# Research Plan\n\nMethod: run one fixed sample.\n"); fs.writeFileSync(path.join(project, "CONSTRAINTS.md"), "# Constraints\n\nLimit: keep the first task small.\n"); + const bootstrapPrd = path.join( + root, + ".trellis", + "tasks", + "00-bootstrap-guidelines", + "prd.md", + ); + fs.appendFileSync( + bootstrapPrd, + [ + "", + "## Decision", + "Verify one agreed sample before expanding the project.", + "", + "## Rationale", + "A fixed sample provides the smallest observable result.", + "", + "## Evidence", + "The project contract records the agreed sample and constraints.", + "", + "## Alternatives", + "Build a larger evaluation workflow first.", + "", + "## Failure Conditions", + "The sample cannot be reproduced under the recorded constraints.", + "", + ].join("\n"), + "utf-8", + ); + const closure = (args: string[]) => run(root, "closure.py", args); - expect(closure(["grill", "--task", "00-bootstrap-guidelines", "--complete"]).status).toBe(0); + expect(closure([ + "grill", "--task", "00-bootstrap-guidelines", "--complete", + "--decision-ref", "prd.md", + ]).status).toBe(0); expect(closure(["validate", "--task", "00-bootstrap-guidelines"]).status).toBe(0); expect(closure(["package-start", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); expect(closure(["package-check", "--task", "00-bootstrap-guidelines", "--package-id", "WP1"]).status).toBe(0); diff --git a/packages/cli/test/commands/init.integration.test.ts b/packages/cli/test/commands/init.integration.test.ts index ad32d5b..20bd333 100644 --- a/packages/cli/test/commands/init.integration.test.ts +++ b/packages/cli/test/commands/init.integration.test.ts @@ -30,7 +30,7 @@ import { init } from "../../src/commands/init.js"; import { VERSION } from "../../src/constants/version.js"; import { DIR_NAMES, FILE_NAMES, PATHS } from "../../src/constants/paths.js"; import { collectPlatformTemplates } from "../../src/configurators/index.js"; -import { computeHash } from "../../src/utils/template-hash.js"; +import { computeHash, loadHashes } from "../../src/utils/template-hash.js"; import { execSync } from "node:child_process"; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -324,6 +324,12 @@ describe("init() integration", () => { expect(fs.existsSync(path.join(tmpDir, ".github", "copilot"))).toBe(false); expect(fs.existsSync(path.join(tmpDir, ".factory"))).toBe(false); expect(fs.existsSync(path.join(tmpDir, ".pi"))).toBe(false); + const claudeEntry = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8"); + expect(claudeEntry.length).toBeLessThan(300); + expect(claudeEntry).toContain("AGENTS.md"); + expect(claudeEntry).toContain(".trellis/project/PROJECT_INDEX.md"); + expect(claudeEntry).toContain("Task Capsule"); + expect(claudeEntry).toContain("HANDOFF.md"); expect( fs.existsSync( path.join(tmpDir, ".claude", "skills", "trellis-meta", "SKILL.md"), @@ -342,6 +348,16 @@ describe("init() integration", () => { ).toBe(true); }); + it("preserves an existing root CLAUDE.md even during force init", async () => { + const existing = "# Team Claude instructions\n\nKeep this file.\n"; + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), existing, "utf-8"); + + await init({ yes: true, claude: true, force: true }); + + expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(existing); + expect(loadHashes(tmpDir)).not.toHaveProperty("CLAUDE.md"); + }); + it("#3 multi platform creates all selected platform directories", async () => { await init({ yes: true, claude: true, cursor: true, opencode: true }); diff --git a/packages/cli/test/commands/uninstall.integration.test.ts b/packages/cli/test/commands/uninstall.integration.test.ts index 978f7bc..6852ef5 100644 --- a/packages/cli/test/commands/uninstall.integration.test.ts +++ b/packages/cli/test/commands/uninstall.integration.test.ts @@ -201,6 +201,17 @@ describe("uninstall() integration", () => { expect(fs.existsSync(userHookDir)).toBe(true); }); + it("preserves a project-owned root CLAUDE.md on uninstall", async () => { + const existing = "# Project-owned Claude rules\n"; + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), existing, "utf-8"); + await init({ yes: true, claude: true, force: true }); + expect(loadHashes(tmpDir)).not.toHaveProperty("CLAUDE.md"); + + await uninstall({ yes: true }); + + expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(existing); + }); + it("#8a empty managed sub-dirs and root dir are pruned (kilo: no structured config)", async () => { // Kilo has no hooks.json/settings.json/config.toml/package.json — every // manifest file is opaque and gets deleted, so the entire .kilocode/ diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index d1da0e6..54b9550 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -365,6 +365,18 @@ describe("update() integration", () => { expect(fs.readFileSync(targetFull, "utf-8")).toBe(templateContent); }); + it("preserves an untracked root CLAUDE.md during force update", async () => { + const existing = "# Project-owned Claude rules\n"; + writeProjectFile("CLAUDE.md", existing); + await init({ yes: true, force: true, claude: true }); + expect(readHashesV2(hashFilePath())).not.toHaveProperty("CLAUDE.md"); + + await update({ force: true }); + + expect(readProjectFile("CLAUDE.md")).toBe(existing); + expect(readHashesV2(hashFilePath())).not.toHaveProperty("CLAUDE.md"); + }); + it("#4bb auto-updates the Claude subagent hook when the installed template is older but unmodified", async () => { await init({ yes: true, force: true, claude: true }); diff --git a/packages/cli/test/configurators/index.test.ts b/packages/cli/test/configurators/index.test.ts index 15f4da7..333e8aa 100644 --- a/packages/cli/test/configurators/index.test.ts +++ b/packages/cli/test/configurators/index.test.ts @@ -339,6 +339,7 @@ describe("collectPlatformTemplates", () => { if (result) { const managedPaths = getPlatformManagedPaths(id); for (const [filePath] of result) { + if (id === "claude-code" && filePath === "CLAUDE.md") continue; expect( managedPaths.some( (managedPath) => @@ -360,6 +361,12 @@ describe("collectPlatformTemplates", () => { } }); + it("tracks the Claude-only root thin entry", () => { + const claude = collectPlatformTemplates("claude-code"); + expect(claude?.get("CLAUDE.md")).toContain("AGENTS.md"); + expect(collectPlatformTemplates("codex")?.has("CLAUDE.md")).toBe(false); + }); + it("tracks bundled trellis-meta files for every skill-writing platform", () => { for (const [id, skillRoot] of Object.entries(SKILL_ROOTS)) { const result = collectPlatformTemplates(id as AITool); diff --git a/packages/cli/test/configurators/shared.test.ts b/packages/cli/test/configurators/shared.test.ts index d9fb1e8..08016dd 100644 --- a/packages/cli/test/configurators/shared.test.ts +++ b/packages/cli/test/configurators/shared.test.ts @@ -516,6 +516,24 @@ describe("resolvePlaceholdersNeutral", () => { // --------------------------------------------------------------------------- describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { + it("routes shared skills through research roles without forcing an engineering pipeline", () => { + const skills = new Map( + resolveSkillsNeutral(AI_TOOLS.codex.templateContext).map((skill) => [ + skill.name, + skill.content, + ]), + ); + + expect(skills.get("trellis-brainstorm")).toContain("It is not part of every task"); + expect(skills.get("trellis-grill-me")).toContain("Task size alone is not a trigger"); + expect(skills.get("trellis-before-dev")).toContain("validated `coder` dispatch"); + expect(skills.get("trellis-tdd")).toContain("passing tests do not validate a hypothesis"); + expect(skills.get("trellis-check")).toContain("Pure experiment and literature packages skip this skill"); + expect(skills.get("trellis-break-loop")).toContain("negative scientific result"); + expect(skills.get("trellis-improve-codebase-architecture")).toContain("explicit software-architecture skill"); + expect(skills.get("trellis-update-spec")).toContain("Evaluate this once near closure"); + }); + it("resolveSkillsNeutral includes Hermes audit, provenance, service queue, and quality-gate checks in trellis-check", () => { const skills = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); const check = skills.find((skill) => skill.name === "trellis-check"); @@ -658,15 +676,15 @@ describe("resolveSkillsNeutral / resolveAllAsSkillsNeutral", () => { } }); - it("resolveAllAsSkillsNeutral keeps the 5 shared skills byte-identical to resolveSkillsNeutral", () => { + it("resolveAllAsSkillsNeutral keeps shared skills byte-identical to resolveSkillsNeutral", () => { const all = resolveAllAsSkillsNeutral(AI_TOOLS.codex.templateContext); - const fiveOnly = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); - const sharedNames = new Set(fiveOnly.map((s) => s.name)); + const sharedOnly = resolveSkillsNeutral(AI_TOOLS.codex.templateContext); + const sharedNames = new Set(sharedOnly.map((s) => s.name)); const allShared = all.filter((s) => sharedNames.has(s.name)); - expect(allShared.length).toBe(fiveOnly.length); - for (const five of fiveOnly) { - const match = allShared.find((s) => s.name === five.name); - expect(match?.content).toBe(five.content); + expect(allShared.length).toBe(sharedOnly.length); + for (const shared of sharedOnly) { + const match = allShared.find((s) => s.name === shared.name); + expect(match?.content).toBe(shared.content); } }); }); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index cd488ac..f03e3a5 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -5250,7 +5250,7 @@ describe("regression: research agent persists findings to task dir", () => { expect(content).not.toMatch(/^- Modify any files\s*$/m); }); - it("codex research.toml uses workspace-write sandbox and persist instruction", () => { + it("codex research.toml uses direct workspace access and persist instruction", () => { const content = fs.readFileSync( path.join( repoRoot, @@ -5258,7 +5258,7 @@ describe("regression: research agent persists findings to task dir", () => { ), "utf-8", ); - expect(content).toMatch(/sandbox_mode\s*=\s*"workspace-write"/); + expect(content).not.toContain("sandbox_mode"); expect(content).toContain("{TASK_DIR}/research/"); expect(content).toMatch(/persist|Persist/); }); @@ -5457,7 +5457,7 @@ describe("regression: Gemini CLI 0.40.x template compatibility (#224)", () => { `Codex and Gemini disagree on ${filePath} — last-writer-wins would corrupt the shared skill`, ).toBe(codexContent); } - // At least the 5 shared workflow skills + bundled trellis-meta files must + // Shared workflow skills + bundled trellis-meta files must // overlap. If this drops to 0 the assertion above is silently passing. expect(overlapCount).toBeGreaterThan(0); }); @@ -5509,8 +5509,8 @@ describe("regression: Gemini CLI 0.40.x template compatibility (#224)", () => { }); it("[#224] needsCodexUpgrade looks for Codex-only command-as-skill markers, not bare `.agents/skills/` prefix", () => { - // Regression: with Gemini also writing to `.agents/skills/` (5 shared - // workflow skills only), the legacy-Codex detector previously triggered + // Regression: with Gemini also writing shared skills to `.agents/skills/` + // (workflow skills only), the legacy-Codex detector previously triggered // a false-positive `.codex/` install on every fresh `init --gemini` + // `update` cycle. The fix narrows detection to Codex-only files // (`trellis-continue/SKILL.md`, `trellis-finish-work/SKILL.md`) which diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index d8cb460..fa81ab6 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -277,7 +277,7 @@ describe("Agent Context Firewall dispatch CLI", () => { })); expect(applied.status, applied.stderr).toBe(0); const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); - expect(handoff).toContain(""); + expect(handoff).toContain("# Task Handoff"); expect(handoff).toContain("- .trellis/tasks/demo/HANDOFF.md"); }); @@ -461,7 +461,7 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.forbidden_files).toContain("generated/**"); }); - it("allows publication packet creation but refuses execution without a hard gate", () => { + it("allows publication packet creation with the compact protocol", () => { writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); const result = run( [ @@ -471,7 +471,7 @@ describe("Agent Context Firewall dispatch CLI", () => { { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, ); expect(result.status).toBe(0); - expect(result.stderr).toContain("execution and close remain blocked"); + expect(result.stderr).toContain("validated dispatch and Result Envelope protocol"); const execution = run( [ "run", "--task", "demo", "--job-id", "job-publication", @@ -479,8 +479,8 @@ describe("Agent Context Firewall dispatch CLI", () => { ], { TRELLIS_HOOKS: "0", TRELLIS_HOOKS_ACTIVE: "1" }, ); - expect(execution.status).toBe(1); - expect(execution.stderr).toContain("advisory_forbidden"); + expect(execution.status).toBe(0); + expect(execution.stdout).toContain('"status": "advisory"'); }); it("rejects invalid JSON, missing uncertainties, long logs, and full diffs", () => { @@ -882,216 +882,6 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(guard.stdout).toBe(""); }); - it("runs Codex strict through output-schema/json/o and stores only sanitized output", () => { - expect(create("job-codex-strict").status).toBe(0); - const fakeCodex = path.join(root, "fake-codex.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('src/app.ts').write_text('strict change\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Strict result.', 'uncertainties': [], 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review package.'}), encoding='utf-8')", - "print(json.dumps({'type': 'trace', 'private': 'raw codex trace'}))", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-strict", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("Strict result."); - expect(result.stdout).not.toContain("raw codex trace"); - const dispatch = JSON.parse( - fs.readFileSync( - path.join(taskDir, "hermes", "dispatches", "job-codex-strict.dispatch.json"), - "utf-8", - ), - ); - expect(dispatch.execution_mode).toBe("strict"); - expect(dispatch.status).toBe("confirmed"); - }); - - it("excludes environment and credential files from the Codex strict workspace", () => { - expect(create("job-codex-secret-workspace").status).toBe(0); - fs.writeFileSync(path.join(root, ".env"), "SECRET_VALUE=do-not-copy\n", "utf-8"); - fs.writeFileSync(path.join(root, ".npmrc"), "//registry.example/:_authToken=do-not-copy\n", "utf-8"); - fs.mkdirSync(path.join(root, "config"), { recursive: true }); - fs.writeFileSync( - path.join(root, "config", "service-account.json"), - `token=${["github_pat_", "B".repeat(24)].join("")}\n`, - "utf-8", - ); - const fakeCodex = path.join(root, "fake-codex-secret-workspace.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "if pathlib.Path('.env').exists() or pathlib.Path('.npmrc').exists() or pathlib.Path('config/service-account.json').exists():", - " raise SystemExit('sensitive file copied into strict workspace')", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Sensitive files were excluded.', 'uncertainties': [], 'changed_files': [], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-secret-workspace", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status, result.stderr).toBe(0); - expect(fs.existsSync(path.join(root, ".env"))).toBe(true); - expect(fs.existsSync(path.join(root, ".npmrc"))).toBe(true); - expect(fs.existsSync(path.join(root, "config", "service-account.json"))).toBe(true); - }); - - it("rejects Codex strict when actual changes escape allowed files", () => { - expect(create("job-codex-escape").status).toBe(0); - fs.writeFileSync(path.join(root, "README.md"), "original\n", "utf-8"); - const originalReadme = fs.readFileSync(path.join(root, "README.md"), "utf-8"); - const fakeCodex = path.join(root, "fake-codex-escape.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('README.md').write_text('unauthorized\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Escaped.', 'uncertainties': [], 'changed_files': ['README.md'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-escape", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - expect(result.status).toBe(1); - expect(result.stderr).toContain("unauthorized_actual_changes"); - expect(fs.readFileSync(path.join(root, "README.md"), "utf-8")).toBe(originalReadme); - }); - - it("does not sync strict workspace changes when the result envelope is invalid", () => { - expect(create("job-codex-invalid").status).toBe(0); - const originalSource = fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8"); - const fakeCodex = path.join(root, "fake-codex-invalid.py"); - fs.writeFileSync( - fakeCodex, - [ - "#!/usr/bin/env python3", - "import json, pathlib, re, sys", - "args = sys.argv[1:]", - "if args == ['exec', '--help']:", - " print('codex exec --output-schema --json -o')", - " raise SystemExit(0)", - "output = pathlib.Path(args[args.index('-o') + 1])", - "prompt = args[-1]", - "job = re.search(r'^job_id: (.+)$', prompt, re.M).group(1)", - "revision = int(re.search(r'@ revision (\\d+)$', prompt, re.M).group(1))", - "role, profile = re.search(r'^role: ([^:]+):(.+)$', prompt, re.M).groups()", - "pathlib.Path('src/app.ts').write_text('untrusted strict change\\n', encoding='utf-8')", - "output.write_text(json.dumps({'schema': 'hermes-result/v1', 'job_id': job, 'task_revision': revision, 'role': role, 'profile': profile, 'status': 'success', 'conclusion': 'Missing required uncertainties.', 'changed_files': ['src/app.ts'], 'evidence_refs': [], 'artifact_refs': [], 'verification': {'status': 'not_recorded', 'run_refs': []}, 'risks': [], 'next_action': 'Review.'}), encoding='utf-8')", - "", - ].join("\n"), - "utf-8", - ); - fs.chmodSync(fakeCodex, 0o755); - - const result = run([ - "run", "--task", "demo", "--job-id", "job-codex-invalid", - "--platform", "codex", "--mode", "strict", "--codex-bin", fakeCodex, - ]); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("missing_result_fields"); - expect(fs.readFileSync(path.join(root, "src", "app.ts"), "utf-8")).toBe( - originalSource, - ); - const dispatch = JSON.parse( - fs.readFileSync( - path.join(taskDir, "hermes", "dispatches", "job-codex-invalid.dispatch.json"), - "utf-8", - ), - ) as { status: string; invalid_result_attempts: number; last_result_error: string }; - expect(dispatch).toMatchObject({ - status: "rewrite_required", - invalid_result_attempts: 1, - last_result_error: "missing_result_fields", - }); - }); - - it("degrades missing Codex strict capability by closure mode", () => { - expect(create("job-codex-lean").status).toBe(0); - const lean = run([ - "run", "--task", "demo", "--job-id", "job-codex-lean", - "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", - ]); - expect(lean.status).toBe(0); - expect(lean.stdout).toContain('"status": "advisory"'); - - writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); - expect(create("job-codex-publication", "planner").status).toBe(0); - const publication = run([ - "run", "--task", "demo", "--job-id", "job-codex-publication", - "--platform", "codex", "--mode", "strict", "--codex-bin", "missing-codex-bin", - ]); - expect(publication.status).toBe(1); - expect(publication.stderr).toContain("codex_strict_unavailable"); - }); - - it("does not accept environment flags or unsigned future heartbeats as a hard gate", () => { - writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "standard" })); - expect(create("job-forged", "planner", ["--platform", "codex"]).status).toBe(0); - const heartbeatDir = path.join(root, ".trellis", ".runtime", "context-firewall"); - fs.mkdirSync(heartbeatDir, { recursive: true }); - writeJson(path.join(heartbeatDir, "codex-hooks-forged.json"), { - platform: "codex", - mechanism: "hooks", - task_id: "demo", - job_id: "job-forged", - timestamp: "2999-01-01T00:00:00Z", - }); - const forged = run( - [ - "run", "--task", "demo", "--job-id", "job-forged", - "--platform", "codex", "--mode", "native", - ], - { TRELLIS_HOOKS_ACTIVE: "1", TRELLIS_CODEX_STRICT: "1" }, - ); - expect(forged.status).toBe(1); - expect(forged.stderr).toContain("firewall_unavailable"); - }); it("supersedes a blocked dispatch and restores the package mechanically", () => { expect(create("job-old").status).toBe(0); diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 1af183e..ed4d288 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -32,6 +32,7 @@ interface TestTask { research_route?: string; research_change_fields?: string[]; grill_completed?: boolean; + decision_ref?: string | null; } function hasPython(): boolean { @@ -188,6 +189,37 @@ function plan(root: string, doneWhen: string[], extra: string[] = []) { return run(root, ...args); } +function writeDecisionRecord( + taskDir: string, + filename = "design.md", + marker = "DECISION_BODY_MUST_NOT_BE_IN_CAPSULE", +): string { + fs.writeFileSync( + path.join(taskDir, filename), + [ + "# Research Decision", + "", + "## Decision", + marker, + "", + "## Rationale", + "The choice follows the current research objective.", + "", + "## Evidence", + "Repository evidence supports this bounded choice.", + "", + "## Alternatives", + "Keep the previous protocol.", + "", + "## Failure Conditions", + "Conflicting evidence or a failed critical assumption.", + "", + ].join("\n"), + "utf-8", + ); + return filename; +} + function planTwoExplicitPackages(root: string) { return plan(root, ["Result A", "Result B"], [ "--package", @@ -261,12 +293,56 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(blocked.status).toBe(1); expect(blocked.stderr).toContain("completed grill"); - expect(run(root, "grill", "--complete").status).toBe(0); + const missingDecision = run(root, "grill", "--complete"); + expect(missingDecision.status).not.toBe(0); + expect(missingDecision.stderr).toContain("--decision-ref"); + + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); expect(run(root, "validate").status).toBe(0); const capsule = run(root, "capsule"); expect(capsule.stdout).toContain("Route: exploration"); expect(capsule.stdout).toContain("Research changes: model_architecture"); expect(capsule.stdout).toContain("Grill: complete"); + expect(capsule.stdout).toContain("Decision ref: design.md"); + expect(capsule.stdout).not.toContain("DECISION_BODY_MUST_NOT_BE_IN_CAPSULE"); + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(events.find((event) => event.event_type === "grill_completed")).toMatchObject({ + decision_ref: "design.md", + }); + }); + + it("keeps legacy completed grills without decision_ref compatible with a warning", () => { + writeTask(taskDir, baseTask({ + intent: "Preserve a legacy exploration task", + definition_of_done: ["Legacy result is verified"], + research_route: "exploration", + research_change_fields: ["dataset"], + grill_completed: true, + work_packages: [ + { + id: "WP1", + title: "Legacy result", + outcome: "Legacy result is verified", + done_when: ["Legacy result is verified"], + evidence_required: [], + depends_on: [], + status: "pending", + evidence_refs: [], + blocker: null, + }, + ], + })); + + const result = run(root, "validate"); + expect(result.status).toBe(0); + expect(result.stderr).toContain("legacy completed grill has no decision_ref"); }); it("automatically routes recorded research changes to exploration", () => { @@ -631,7 +707,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); - it("requires a current handoff before an open task session can finish", () => { + it("updates a handoff before an open task session finishes", () => { expect(plan(root, ["Result A"]).status).toBe(0); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); fs.mkdirSync(sessions, { recursive: true }); @@ -639,22 +715,13 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { path.join(sessions, "closure-test-session.json"), `${JSON.stringify({ current_task: ".trellis/tasks/demo" })}\n`, ); - const blocked = runTask(root, "finish"); - expect(blocked.status).toBe(1); - expect(blocked.stdout).toContain("dedicated handoff subagent"); - expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(false); - - const task = readTask(taskDir); - fs.writeFileSync( - path.join(taskDir, "HANDOFF.md"), - `\n`, - ); const result = runTask(root, "finish"); expect(result.status).toBe(0); - expect(result.stdout).not.toContain("Handoff updated"); + expect(result.stdout).toContain("Handoff updated"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(true); }); - it("writes revision-bound handoffs from validated task results only", () => { + it("writes handoffs from validated task results only", () => { expect(plan(root, ["Result A"]).status).toBe(0); fs.mkdirSync(path.join(taskDir, "hermes", "dispatches"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); @@ -696,27 +763,13 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(run(root, "handoff").status).toBe(0); const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); - expect(handoff).toContain( - ``, - ); + expect(handoff).toContain("# Task Handoff"); expect(handoff).not.toContain("- src/owned.ts"); expect(handoff).not.toContain("src/forged.ts"); expect(handoff).not.toContain("unrelated.txt"); expect(handoff).toContain("job-1: confirmed result integrity check failed"); }); - it("refuses to replace a handoff symlink", () => { - if (process.platform === "win32") return; - expect(plan(root, ["Result A"]).status).toBe(0); - const sentinel = path.join(root, "sentinel.md"); - fs.writeFileSync(sentinel, "do not overwrite\n"); - fs.symlinkSync(sentinel, path.join(taskDir, "HANDOFF.md")); - - const result = run(root, "handoff"); - expect(result.status).toBe(1); - expect(result.stderr).toContain("crosses a symlink"); - expect(fs.readFileSync(sentinel, "utf-8")).toBe("do not overwrite\n"); - }); it("derives next action from current closure state instead of stale task text", () => { expect(plan(root, ["Result A"]).status).toBe(0); @@ -743,7 +796,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(result.stdout.length).toBeLessThan(800); }); - it("strongly warns when lean closure runs without hooks or strict heartbeat", () => { + it("warns when lean closure runs without a context hook", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); const result = spawnSync( @@ -756,7 +809,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }, ); expect(result.stdout).toContain("warnings:"); - expect(result.stdout).toContain("advisory"); + expect(result.stdout).toContain("validated dispatch"); }); it("repairs only the first gap and preserves completed packages", () => { @@ -874,10 +927,85 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.research_change_fields).toEqual(["dataset"]); expect(task.grill_completed).toBe(false); expect(run(root, "validate").status).toBe(1); - expect(run(root, "grill", "--complete").status).toBe(0); + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); expect(run(root, "validate").status).toBe(0); }); + it("preserves the completed decision until a research amendment is applied", () => { + expect( + plan(root, ["Research result is verified"], [ + "--route", + "exploration", + "--research-change", + "dataset", + ]).status, + ).toBe(0); + const decisionRef = writeDecisionRecord(taskDir); + expect( + run(root, "grill", "--complete", "--decision-ref", decisionRef).status, + ).toBe(0); + expect(run(root, "validate").status).toBe(0); + + const taskWithInvalidNestedTarget = readTask(taskDir); + taskWithInvalidNestedTarget.meta.research_contract = { dataset: "dataset-v1" }; + writeTask(taskDir, taskWithInvalidNestedTarget); + + const denied = run( + root, + "amend", + "--field", + "split", + "--value", + "split-v2", + "--reason", + "unapproved split change", + ); + expect(denied.status).toBe(1); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: decisionRef, + grill_completed: true, + }); + + const invalid = run( + root, + "amend", + "--field", + "dataset.version", + "--value", + "v2", + "--reason", + "invalid nested dataset change", + "--approved-by", + "human/root", + ); + expect(invalid.status).toBe(1); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: decisionRef, + grill_completed: true, + }); + + const approved = run( + root, + "amend", + "--field", + "split", + "--value", + "split-v2", + "--reason", + "approved split change", + "--approved-by", + "human/root", + ); + expect(approved.status).toBe(0); + expect(readTask(taskDir)).toMatchObject({ + decision_ref: null, + grill_completed: false, + }); + }); + it("treats functional model architecture changes as high risk", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -1067,33 +1195,6 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).closure_state).toBe("open"); }); - it("does not close publication mode when hooks are explicitly disabled", () => { - expect( - plan(root, ["Publication result verified"], ["--mode", "publication"]) - .status, - ).toBe(0); - expect(run(root, "validate").status).toBe(0); - completeCurrent(root, "ev-1"); - const result = spawnSync( - PYTHON, - [".trellis/scripts/closure.py", "close", "--task", "demo"], - { - cwd: root, - encoding: "utf-8", - env: { - ...process.env, - TRELLIS_HOOKS: "0", - TRELLIS_HOOKS_ACTIVE: "1", - }, - }, - ); - expect(result.status).toBe(1); - expect(result.stdout).toContain("context firewall"); - const task = readTask(taskDir); - expect(task.status).toBe("in_progress"); - expect(task.closure_state).toBe("open"); - expect(task.hermes_phase).not.toBe("closed"); - }); it("keeps legacy non-Hermes archive behavior", () => { const legacyRoot = fs.mkdtempSync( diff --git a/packages/cli/test/scripts/hermes-preflight.test.ts b/packages/cli/test/scripts/hermes-preflight.test.ts index 05b7fa5..28af902 100644 --- a/packages/cli/test/scripts/hermes-preflight.test.ts +++ b/packages/cli/test/scripts/hermes-preflight.test.ts @@ -6,9 +6,8 @@ import path from "node:path"; import { checkHermesRequiredFiles, checkHookMatcherIncludes, + checkLocalRunnerConfiguration, checkNoPythonCaches, - checkSandboxConfiguration, - checkSecurityGateDocumentation, } from "../../scripts/hermes-preflight.js"; const repoRoot = path.resolve(import.meta.dirname, "../../../../"); @@ -74,89 +73,16 @@ describe("hermes-preflight checks", () => { }); }); - it("requires deployment docs to state Bash and approval security boundaries", () => { + it("checks direct local runner templates", () => { withTempRoot((root) => { - writeFile( - root, - "docs/hermes_deployment_preflight.md", - "`Bash` is partially guarded.\n", - ); - - const result = checkSecurityGateDocumentation(root); - - expect(result.ok).toBe(false); - expect(result.messages.join("\n")).toContain("approval_records"); - expect(result.messages.join("\n")).toContain("human/root"); - }); - }); - - it("requires deployment docs to state hardening and storage boundaries", () => { - withTempRoot((root) => { - writeFile( - root, - "docs/hermes_deployment_preflight.md", - [ - "`deployment candidate hardening` is the scope.", - "`Bash` writes fail closed.", - "`allowed_commands` is not a strong command sandbox.", - "`not an OS sandbox`.", - "`approval_records` require `external human/root approval`.", - "", - ].join("\n"), - ); - - const result = checkSecurityGateDocumentation(root); - - expect(result.ok).toBe(false); - expect(result.messages.join("\n")).toContain("JSONL"); - expect(result.messages.join("\n")).toContain("not tamper-proof"); - }); - }); - - it("checks sandbox configuration defaults and documentation boundaries", () => { - withTempRoot((root) => { - writeFile( - root, - "packages/cli/src/templates/trellis/hermes/config.yaml", - [ - 'runtime_scope: "deployment candidate hardening"', - "sandbox:", - ' mode: "none"', - " required: false", - ' note: "not an OS sandbox"', - "", - ].join("\n"), - ); - writeFile( - root, - "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", - [ - "sandbox:", - ' mode: "none"', - " required: false", - "", - ].join("\n"), - ); - writeFile( - root, - "docs/hermes_deployment_preflight.md", - [ - "`sandbox.required=true` with `mode=none` must fail closed.", - "`mode=none` is only local execution.", - "`container` and `external` modes are availability checks only.", - "`not an OS sandbox`.", - "`allowed_commands` is not a strong command sandbox.", - "", - ].join("\n"), - ); - - const result = checkSandboxConfiguration(root); - + writeFile(root, "packages/cli/src/templates/trellis/hermes/config.yaml", 'runtime_boundary: "runs directly in the project workspace"\n'); + writeFile(root, "packages/cli/src/templates/trellis/hermes/experiments/experiment.yaml", "allowed_commands:\n - \"python3\"\nartifact_dir: \".trellis/tasks/{task}/hermes/runs\"\n"); + const result = checkLocalRunnerConfiguration(root); expect(result.ok).toBe(true); }); }); - it("quick CLI runs security gate without build/typecheck", () => { + it("quick CLI runs local runner checks without build/typecheck", () => { const result = spawnSync( process.execPath, [scriptPath, "--", "--quick", "--skip-python-compile"], @@ -167,9 +93,8 @@ describe("hermes-preflight checks", () => { ); expect(result.status).toBe(0); - expect(result.stdout).toContain("Hermes deployment preflight"); + expect(result.stdout).toContain("Hermes local workflow preflight"); expect(result.stdout).toContain("quick mode"); - expect(result.stdout).toContain("Bash"); - expect(result.stdout).toContain("approval_records"); + expect(result.stdout).toContain("current project workspace"); }); }); diff --git a/packages/cli/test/templates/codex.test.ts b/packages/cli/test/templates/codex.test.ts index 9912f08..19a00a6 100644 --- a/packages/cli/test/templates/codex.test.ts +++ b/packages/cli/test/templates/codex.test.ts @@ -69,19 +69,37 @@ describe("codex getAllAgents", () => { } }); - it("marks native Hermes agents advisory and points enforced work to strict dispatch", () => { + it("keeps Hermes agents on the compact dispatch protocol", () => { const agents = new Map(getAllAgents().map((agent) => [agent.name, agent.content])); for (const role of ["planner", "researcher", "coder", "runner", "reviewer"]) { const content = agents.get(`hermes-${role}`); - expect(content).toContain("Native Codex dispatch is advisory"); expect(content).toContain(`role: ${role}:`); expect(content).toMatch(/Profile|profile/); expect(content).toContain("multi_agent = false"); - expect(content).toContain("--mode strict"); expect(content).toContain("Result Envelope JSON"); + expect(content).not.toContain("sandbox_mode"); + expect(content).not.toContain("--mode strict"); expect(content).not.toContain(".trellis/tasks//prd.md"); } }); + + it("assigns research reasoning and execution models consistently", () => { + const agents = new Map(getAllAgents().map((agent) => [agent.name, agent.content])); + for (const name of [ + "hermes-planner", + "hermes-researcher", + "hermes-reviewer", + "trellis-check", + "trellis-research", + ]) { + expect(agents.get(name)).toContain('model = "gpt-5.6-sol"'); + expect(agents.get(name)).toContain('model_reasoning_effort = "high"'); + } + for (const name of ["hermes-coder", "hermes-runner", "trellis-implement"]) { + expect(agents.get(name)).toContain('model = "gpt-5.6-terra"'); + expect(agents.get(name)).toContain('model_reasoning_effort = "max"'); + } + }); }); describe("codex getAllCodexSkills (platform-specific)", () => { @@ -97,8 +115,10 @@ describe("codex getConfigTemplate", () => { expect(config.targetPath).toBe("config.toml"); expect(config.content).toContain("project_doc_fallback_filenames"); expect(config.content).toContain("AGENTS.md"); - expect(config.content).toContain('native_authority = "advisory"'); - expect(config.content).toContain("--output-schema --json -o"); + expect(config.content).toContain('model = "gpt-5.6-sol"'); + expect(config.content).toContain('model_reasoning_effort = "high"'); + expect(config.content).toContain('native_authority = "protocol"'); + expect(config.content).not.toContain("strict_authority"); }); }); diff --git a/packages/cli/test/templates/hermes-runtime.test.ts b/packages/cli/test/templates/hermes-runtime.test.ts index fcb6c99..570c65a 100644 --- a/packages/cli/test/templates/hermes-runtime.test.ts +++ b/packages/cli/test/templates/hermes-runtime.test.ts @@ -77,9 +77,6 @@ function writeExperimentConfig(taskDir: string): void { ' shell: "bash"', "allowed_commands:", ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: false", 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', "", ].join("\n"), @@ -106,9 +103,6 @@ function writeExperimentConfigWithAllowedCommands( ' shell: "bash"', "allowed_commands:", ...allowedCommands.map((command) => ` - "${command}"`), - "sandbox:", - ' mode: "none"', - " required: false", 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', "", ].join("\n"), @@ -116,57 +110,68 @@ function writeExperimentConfigWithAllowedCommands( ); } -function writeFakeDocker(root: string): { binDir: string; logPath: string } { - const binDir = path.join(root, "fake-bin"); - const logPath = path.join(root, "fake-docker-argv.json"); - fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync( - path.join(binDir, "docker"), + +function sha256Text(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function writeRunnerCard(taskDir: string, jobId: string): void { + writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ + { + type: "task_card", + id: `tc-${jobId}`, + timestamp: "2026-06-29T00:00:00Z", + job_id: jobId, + role: "runner", + worktree_id: "main", + status: "queued", + allowed_files: ["**"], + forbidden_files: [], + heartbeat_interval: "1s", + timeout_at: "2099-01-01T00:00:00Z", + checkpoint: "not-started", + resume_from: "task_card", + record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", + evidence_refs: [], + risk_flags: [], + }, + ]); +} + +function markDataExploration(taskDir: string): void { + writeJson(path.join(taskDir, "task.json"), { + id: "01-test", + status: "in_progress", + research_route: "exploration", + research_change_fields: ["dataset"], + }); +} + +function appendDataPreflight( + taskDir: string, + hash: string, + checksContent: string, +): { manifestPath: string; checksPath: string } { + const manifestPath = "data/input-manifest.json"; + const checksPath = "data/checks.yaml"; + const root = path.resolve(taskDir, "../../../"); + fs.mkdirSync(path.join(root, "data"), { recursive: true }); + fs.writeFileSync(path.join(root, manifestPath), '{"rows":2}\n', "utf-8"); + fs.writeFileSync(path.join(root, checksPath), checksContent, "utf-8"); + fs.appendFileSync( + path.join(taskDir, "hermes", "experiment.yaml"), [ - "#!/usr/bin/env python3", - "import json", - "import os", - "import subprocess", - "import sys", - `log_path = ${JSON.stringify(logPath)}`, - "args = sys.argv[1:]", - "with open(log_path, 'w', encoding='utf-8') as handle:", - " json.dump(args, handle)", - "if len(args) < 7 or args[0:2] != ['run', '--rm']:", - " sys.exit(64)", - "index = 2", - "host_root = None", - "container_root = None", - "workdir = None", - "while index < len(args):", - " if args[index] in ['-v', '--volume']:", - " host_root, container_root = args[index + 1].split(':', 1)", - " index += 2", - " elif args[index] in ['-w', '--workdir']:", - " workdir = args[index + 1]", - " index += 2", - " else:", - " break", - "if host_root is None or container_root is None or workdir is None:", - " sys.exit(65)", - "if index >= len(args):", - " sys.exit(66)", - "command = args[index + 1:]", - "host_cwd = host_root", - "if workdir != container_root:", - " host_cwd = os.path.join(host_root, os.path.relpath(workdir, container_root))", - "result = subprocess.run(command, cwd=host_cwd)", - "sys.exit(result.returncode)", + "data_preflight:", + ' source: "fixture"', + ' version: "v1"', + ` input_manifest: "${manifestPath}"`, + ` hash: "${hash}"`, + ` checks_ref: "${checksPath}"`, "", ].join("\n"), "utf-8", ); - fs.chmodSync(path.join(binDir, "docker"), 0o755); - return { binDir, logPath }; -} - -function sha256Text(value: string): string { - return `sha256:${createHash("sha256").update(value).digest("hex")}`; + return { manifestPath, checksPath }; } describe("Hermes runtime scripts", () => { @@ -661,6 +666,135 @@ describe("Hermes runtime scripts", () => { )).toBe(""); }); + it("keeps ordinary experiment validation free of data_preflight", () => { + writeExperimentConfig(taskDir); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(0); + }); + + it("blocks a data-changing exploration before runner command execution when data_preflight is missing", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + writeRunnerCard(taskDir, "job-data-preflight-missing"); + + const run = runHermes(tmpDir, "runner.py", [ + "run", + "--task", + "01-test", + "--job-id", + "job-data-preflight-missing", + "--checkpoint", + "data-preflight", + "--summary", + "validate data before execution", + "--", + "python3", + "-c", + "import pathlib; pathlib.Path('should-not-run.txt').write_text('bad')", + ]); + + expect(run.status).toBe(1); + expect(run.stderr).toContain("data_preflight is required"); + expect(fs.existsSync(path.join(tmpDir, "should-not-run.txt"))).toBe(false); + expect(fs.existsSync(path.join(taskDir, "hermes", "run_manifest.jsonl"))).toBe(false); + }); + + it("rejects a data_preflight hash that does not match its input manifest", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + appendDataPreflight( + taskDir, + `sha256:${"0".repeat(64)}`, + "schema: checked\nmissing: checked\nduplicates: checked\nsplit_leakage: not_applicable\n", + ); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(1); + expect(validate.stderr).toContain("data_preflight hash mismatch"); + }); + + it("rejects data_preflight checks with a missing required check", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + const manifestContent = '{"rows":2}\n'; + appendDataPreflight( + taskDir, + sha256Text(manifestContent), + "schema: checked\nmissing: checked\nduplicates: checked\n", + ); + + const validate = runHermes(tmpDir, "experiment.py", [ + "validate", + "--task", + "01-test", + ]); + + expect(validate.status).toBe(1); + expect(validate.stderr).toContain("split_leakage must be checked or not_applicable"); + }); + + it("accepts valid data_preflight and records its files as runner inputs", () => { + markDataExploration(taskDir); + writeExperimentConfig(taskDir); + const manifestContent = '{"rows":2}\n'; + const refs = appendDataPreflight( + taskDir, + sha256Text(manifestContent), + "schema: checked\nmissing: checked\nduplicates: checked\nsplit_leakage: not_applicable\n", + ); + writeRunnerCard(taskDir, "job-data-preflight-valid"); + + const run = runHermes(tmpDir, "runner.py", [ + "run", + "--task", + "01-test", + "--job-id", + "job-data-preflight-valid", + "--checkpoint", + "data-preflight", + "--summary", + "run with validated data", + "--input", + refs.manifestPath, + "--input", + refs.checksPath, + "--output", + "data-run.txt", + "--", + "python3", + "-c", + "import pathlib; pathlib.Path('data/input-manifest.json').write_text('{\"rows\":99}\\n'); pathlib.Path('data-run.txt').write_text('ok')", + ]); + + expect(run.status).toBe(0); + const manifest = fs + .readFileSync(path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(manifest).toHaveLength(1); + expect(manifest[0].inputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: refs.manifestPath, hash: sha256Text(manifestContent) }), + expect.objectContaining({ path: refs.checksPath }), + ]), + ); + expect(sha256Text(fs.readFileSync(path.join(tmpDir, refs.manifestPath), "utf-8"))).not.toBe( + sha256Text(manifestContent), + ); + }); + it("rejects worker results when there is no task_card", () => { fs.writeFileSync( path.join(taskDir, "hermes", "worker_records.jsonl"), @@ -1019,6 +1153,62 @@ describe("Hermes runtime scripts", () => { expect(validate.stderr).toContain("coder review handoff requires reviewer"); }); + it("accepts a configuration handoff without inventing code test records", () => { + writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ + { + type: "task_card", + id: "tc-20260629-000000-config", + timestamp: "2026-06-29T00:00:00Z", + job_id: "job-config", + role: "coder", + profile: "configuration", + worktree_id: "main", + status: "queued", + allowed_files: [".trellis/tasks/01-test/HANDOFF.md"], + forbidden_files: [], + heartbeat_interval: "5m", + timeout_at: "2099-01-01T00:00:00Z", + checkpoint: "not-started", + resume_from: "task_card", + record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", + evidence_refs: [], + risk_flags: [], + }, + { + type: "checkpoint", + id: "cp-20260629-000100-config", + timestamp: "2026-06-29T00:01:00Z", + job_id: "job-config", + checkpoint: "handoff-written", + resume_from: "return result", + evidence_refs: [], + open_items: [], + }, + { + type: "result", + id: "rs-20260629-000200-config", + timestamp: "2026-06-29T00:02:00Z", + job_id: "job-config", + status: "done", + summary: "wrote task handoff", + changed_files: [".trellis/tasks/01-test/HANDOFF.md"], + evidence_refs: [], + risk_flags: [], + handoff: "review", + }, + ]); + + const validate = runHermes(tmpDir, "validate.py", [ + "--task", + "01-test", + "--kind", + "worker", + ]); + + expect(validate.status).toBe(0); + expect(validate.stdout).toContain("valid"); + }); + it("rejects coder review handoff when runner has no result record", () => { writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ { @@ -1665,7 +1855,7 @@ describe("Hermes runtime scripts", () => { }); }); - it("runner wraps a long command with heartbeats and a replayable manifest", () => { + it("runner uses local execution for legacy sandbox configs and records a replayable manifest", () => { const workerRecords = [ { type: "task_card", @@ -1691,6 +1881,11 @@ describe("Hermes runtime scripts", () => { workerRecords.map((record) => JSON.stringify(record)).join("\n") + "\n", ); writeExperimentConfig(taskDir); + fs.appendFileSync( + path.join(taskDir, "hermes", "experiment.yaml"), + 'sandbox:\n mode: "container"\n required: true\n', + "utf-8", + ); const run = runHermes(tmpDir, "runner.py", [ "run", @@ -1984,7 +2179,7 @@ describe("Hermes runtime scripts", () => { ).toBe(false); }); - it("runner run does not pass common secret env vars to subprocess or run_manifest", () => { + it("runner inherits the project runtime environment without recording it", () => { const workerRecords = [ { type: "task_card", @@ -2044,7 +2239,7 @@ describe("Hermes runtime scripts", () => { expect(result.status).toBe(0); expect( fs.readFileSync(path.join(tmpDir, "env-output.txt"), "utf-8"), - ).toBe("missing"); + ).toBe("test-secret-value"); const manifest = fs.readFileSync( path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8", @@ -2053,417 +2248,6 @@ describe("Hermes runtime scripts", () => { expect(manifest).not.toContain("test-secret-value"); }); - it("runner rejects sandbox.required=true when sandbox mode is none", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-none", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run?"', - 'hypothesis: "The command should not run without a sandbox."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "none"', - " required: true", - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - - const run = runHermes(tmpDir, "runner.py", [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-none", - "--checkpoint", - "command-running", - "--summary", - "run without sandbox", - "--", - "python3", - "-c", - "import pathlib; pathlib.Path('should-not-run.txt').write_text('bad', encoding='utf-8')", - ]); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.required=true"); - expect(run.stderr).toContain("mode=none"); - expect(fs.existsSync(path.join(tmpDir, "should-not-run.txt"))).toBe(false); - expect( - fs.existsSync(path.join(taskDir, "hermes", "run_manifest.jsonl")), - ).toBe(false); - }); - - it("runner rejects unavailable container sandbox with an explicit error", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-container", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Missing container runtime fails closed."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - ` command: "${path.join(tmpDir, "__missing_hermes_container_runtime__", "docker")}"`, - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - - const run = runHermes(tmpDir, "runner.py", [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-container", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ]); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox mode container"); - expect(run.stderr).toContain("not available"); - }); - - it("runner rejects container sandbox images that look like docker flags", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-image", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Docker flags must not be accepted as images."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "--network=host"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-image", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.image"); - expect(fs.existsSync(fakeDocker.logPath)).toBe(false); - }); - - it("runner rejects container sandbox commands with docker global options", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-command", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "Docker global options must not be accepted."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - ' command: "docker --context test"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-command", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--", - "python3", - "-c", - "print('should not run')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("sandbox.command"); - expect(fs.existsSync(fakeDocker.logPath)).toBe(false); - }); - - it("runner runs required container sandbox through docker run with the repo mounted", () => { - writeJsonl(path.join(taskDir, "hermes", "worker_records.jsonl"), [ - { - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-runner-sandbox-docker", - role: "runner", - worktree_id: "main", - status: "queued", - allowed_files: ["**"], - forbidden_files: [], - heartbeat_interval: "1s", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: ".trellis/tasks/01-test/hermes/worker_records.jsonl", - evidence_refs: [], - risk_flags: [], - }, - ]); - fs.mkdirSync(path.join(tmpDir, "workspace"), { recursive: true }); - fs.writeFileSync( - path.join(taskDir, "hermes", "experiment.yaml"), - [ - 'question: "Does the command run in a container?"', - 'hypothesis: "The container runner writes the expected artifact."', - 'dataset: "unit fixture"', - 'model: "test runner"', - "metrics:", - ' - "exit_code"', - "seed: 1", - "environment:", - ' os: "ubuntu-24.04"', - ' shell: "bash"', - "allowed_commands:", - ' - "python3"', - "sandbox:", - ' mode: "container"', - " required: true", - ' image: "python:3.12-slim"', - 'artifact_dir: ".trellis/tasks/01-test/hermes/runs"', - "", - ].join("\n"), - "utf-8", - ); - const fakeDocker = writeFakeDocker(tmpDir); - - const run = runHermes( - tmpDir, - "runner.py", - [ - "run", - "--task", - "01-test", - "--job-id", - "job-runner-sandbox-docker", - "--checkpoint", - "command-running", - "--summary", - "run container sandbox", - "--cwd", - "workspace", - "--output", - "container-output.txt", - "--", - "python3", - "-c", - "import pathlib; pathlib.Path('container-output.txt').write_text('container ok', encoding='utf-8')", - ], - { - env: { - ...process.env, - PATH: `${fakeDocker.binDir}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }, - ); - - expect(run.status).toBe(0); - expect( - fs.readFileSync(path.join(tmpDir, "workspace", "container-output.txt"), "utf-8"), - ).toBe("container ok"); - - const dockerArgs = JSON.parse( - fs.readFileSync(fakeDocker.logPath, "utf-8"), - ) as string[]; - expect(dockerArgs.slice(0, 2)).toEqual(["run", "--rm"]); - expect(dockerArgs).toEqual( - expect.arrayContaining([ - "-v", - `${tmpDir}:/workspace`, - "-w", - "/workspace/workspace", - "python:3.12-slim", - "python3", - ]), - ); - - const manifestRows = fs - .readFileSync(path.join(taskDir, "hermes", "run_manifest.jsonl"), "utf-8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - expect(manifestRows[0]).toMatchObject({ - job_id: "job-runner-sandbox-docker", - cwd: "workspace", - exit_code: 0, - sandbox: { - mode: "container", - image: "python:3.12-slim", - }, - }); - expect(manifestRows[0].command).toEqual( - expect.arrayContaining(["python3", "-c"]), - ); - }); it("runner replay passes when output hashes match", () => { const output = "stable output"; diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index fb4c761..0eafad7 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -709,11 +709,15 @@ describe("shared-hooks capability table", () => { expect(context).toContain("Hermes closure task: capsule-task"); expect(context).toContain("Route: delivery"); expect(context).toContain("Route rule:"); + expect(context).toContain("Skill route: coder uses before-dev"); expect(context).toContain(""); + expect(context).toContain( + "Do not regenerate existing work packages for an in-scope continuation", + ); expect(context).toContain( ".trellis/tasks/capsule-task/task.json", ); - expect(context).toContain("No current HANDOFF.md exists"); + expect(context).not.toContain("HANDOFF.md"); expect(context).not.toContain("Claude review gates are read-only gates"); expect(context).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); const capsule = @@ -729,14 +733,17 @@ describe("shared-hooks capability table", () => { hookSpecificOutput: { additionalContext: string }; }; expect(repeated.hookSpecificOutput.additionalContext).toContain( - "Hermes closure update", + "anchor revision 0 remains active", ); - expect(repeated.hookSpecificOutput.additionalContext).toContain( + expect(repeated.hookSpecificOutput.additionalContext).not.toContain( "", ); expect(repeated.hookSpecificOutput.additionalContext).toContain( "", ); + expect(repeated.hookSpecificOutput.additionalContext).toContain( + "Skill route: coder uses before-dev", + ); expect(repeated.hookSpecificOutput.additionalContext).toContain( ".trellis/tasks/capsule-task/HANDOFF.md", ); @@ -771,7 +778,7 @@ describe("shared-hooks capability table", () => { expect(revised.hookSpecificOutput.additionalContext).toContain( "", ); - expect(revised.hookSpecificOutput.additionalContext).toContain( + expect(revised.hookSpecificOutput.additionalContext).not.toContain( "HANDOFF.md is stale", ); @@ -1938,6 +1945,41 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.stderr).toBe(""); }); + it("allows Stop for a recorded experiment despite unrelated worktree changes", () => { + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "src", "app.ts"), "fixture\n"); + commitRepoState(repoRoot, "experiment baseline"); + fs.writeFileSync(path.join(repoRoot, "unrelated-user-note.md"), "keep me\n"); + setSessionActiveTask(repoRoot, taskName); + writeRunManifest(0); + writeHermesWorkerRecords([ + taskCard("job-runner", "runner", { profile: "experiment" }), + checkpoint("job-runner", "experiment-recorded"), + resultRecord("job-runner", "experiment ran", { + changed_files: [], + evidence_refs: ["run-tests"], + }), + taskCard("job-reviewer", "reviewer", { + profile: "evidence", + parent_job_id: "job-runner", + }), + checkpoint("job-reviewer", "evidence-reviewed"), + resultRecord("job-reviewer", "evidence reviewed", { + changed_files: [], + }), + ]); + + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + }); + it("denies main-agent Write before worker file-boundary checks", () => { writeHermesWorkerRecords([ taskCard("job-coder", "coder"), @@ -2081,95 +2123,6 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.stderr).toBe(""); }); - it("denies main-agent git pathspecs with shell expansion syntax", () => { - for (const command of [ - "git diff --name-only -- $PWD", - "git diff --name-only -- ${PWD}", - "git diff --name-only -- $(pwd)", - "git diff --name-only -- `pwd`", - "git diff --name-only -- ~", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("allows main-agent git log oneline with an explicit safe pathspec", () => { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - tool_name: "Bash", - tool_input: { - command: "git log --oneline -- src/app.ts", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("denies main-agent git pathspecs that target env-like paths", () => { - for (const command of [ - "git status --short -- .", - "git diff --name-only -- .ENV", - "git diff --name-only -- ENV/config.ts", - "git diff --name-only -- config/prod.env", - "git diff --name-only -- env/config.ts", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent git summaries without explicit safe pathspecs", () => { - for (const command of [ - "git status", - "git diff --name-only", - "git diff --stat", - "git log --oneline", - "git log --stat", - "git diff --name-only -- '*env*'", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent read-only-looking Bash with shell control or redirection", () => { - for (const command of [ - "git status && cat .trellis/tasks/06-04-hermes-runtime/hermes/worker_records.jsonl", - "git status > out.txt", - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent jq commands that can read non-RecordBus files", () => { - for (const command of [ - `jq . .trellis/tasks/${taskName}/hermes/worker_records.jsonl .env`, - `jq . .trellis/tasks/${taskName}/hermes/worker_records.jsonl tmp/extra.jsonl`, - `jq . .trellis/tasks/${taskName}/hermes/../worker_records.jsonl`, - `jq -f filter.jq .trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - `jq --rawfile secret .env . .trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - ]) { - expectMainAgentBashDenied(command); - } - }); - - it("denies main-agent git commands with unsafe read-only options", () => { - for (const command of [ - "git diff --output patch.txt", - "git diff --output=patch.txt", - "git diff --ext-diff", - "git diff --external-diff", - "git --exec-path=/tmp diff", - "git --config core.pager=cat diff", - "git -c core.pager=cat diff", - "git diff --no-index .env /dev/null", - "git log -p -- .env", - ]) { - expectMainAgentBashDenied(command); - } - }); it("denies main-agent execution and mutating git Bash commands", () => { for (const command of [ @@ -2969,389 +2922,6 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("denies PreToolUse Bash commands that redirect to unauthorized files", () => { - const hermesDir = path.join( - repoRoot, - ".trellis", - "tasks", - taskName, - "hermes", - ); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.writeFileSync( - path.join(hermesDir, "worker_records.jsonl"), - `${JSON.stringify({ - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-active", - role: "coder", - worktree_id: "main", - status: "queued", - allowed_files: ["src/**"], - forbidden_files: ["secrets/**"], - heartbeat_interval: "5m", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: `.trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - evidence_refs: [], - risk_flags: [], - })}\n${JSON.stringify({ - type: "heartbeat", - id: "hb-20260629-000100-demo", - timestamp: "2026-06-29T00:01:00Z", - job_id: "job-active", - status: "running", - checkpoint: "files-read", - summary: "active", - next_check_at: "2099-01-01T00:00:00Z", - })}\n`, - ); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf secret > secrets/out.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "outside allowed_files", - ); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "secrets/out.txt", - ); - }); - - it("denies PreToolUse Bash tee commands when any target is unauthorized", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf ok | tee src/ok.txt docs/bad.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "outside allowed_files", - ); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "docs/bad.txt", - ); - }); - - it("treats tee operands after -- as targets even when they start with a dash", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "printf ok | tee -- -secret src/ok.txt", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const decision = parseDecision(result); - expect(decision.permissionDecision).toBe("deny"); - expect(decision.permissionDecisionReason).toContain("outside allowed_files"); - expect(decision.permissionDecisionReason).toContain("-secret"); - }); - - it("denies PreToolUse Bash commands with write-like content when targets cannot be parsed", () => { - const hermesDir = path.join( - repoRoot, - ".trellis", - "tasks", - taskName, - "hermes", - ); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.writeFileSync( - path.join(hermesDir, "worker_records.jsonl"), - `${JSON.stringify({ - type: "task_card", - id: "tc-20260629-000000-demo", - timestamp: "2026-06-29T00:00:00Z", - job_id: "job-active", - role: "coder", - worktree_id: "main", - status: "queued", - allowed_files: ["src/**"], - forbidden_files: [], - heartbeat_interval: "5m", - timeout_at: "2099-01-01T00:00:00Z", - checkpoint: "not-started", - resume_from: "task_card", - record_uri: `.trellis/tasks/${taskName}/hermes/worker_records.jsonl`, - evidence_refs: [], - risk_flags: [], - })}\n${JSON.stringify({ - type: "heartbeat", - id: "hb-20260629-000100-demo", - timestamp: "2026-06-29T00:01:00Z", - job_id: "job-active", - status: "running", - checkpoint: "files-read", - summary: "active", - next_check_at: "2099-01-01T00:00:00Z", - })}\n`, - ); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: - "python3 -c \"import pathlib, os; pathlib.Path(os.environ['TARGET']).write_text('x')\"", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - }); - - it("denies PreToolUse link creation for a dispatched coder", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "ln -s /tmp/escape .trellis/tasks/06-04-hermes-runtime/HANDOFF.md", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "link creation is not allowed", - ); - }); - - it("denies PreToolUse Bash scripts with unparsed destructive file APIs", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command: "python3 -c \"import os; os.remove('docs/bad.txt')\"", - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - }); - - it("denies PreToolUse Node inline async file APIs when targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "node -e \"require('fs').writeFile('docs/bad.txt','x',()=>{})\"", - "node -e \"require('fs').copyFileSync('src/app.ts','docs/bad.txt')\"", - "node -e \"const fs = require('fs'); fs.promises.writeFile('docs/bad.txt','x')\"", - "node -e \"require('fs').promises.copyFile('src/app.ts','docs/bad.txt')\"", - "node -e \"const fs = require('fs'); fs.promises.rm('docs/bad.txt')\"", - "node -pe \"const { writeFileSync: w } = require('node:fs'); w('docs/bad.txt','x')\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); - - it("denies PreToolUse Python, Ruby, and Perl inline copy and move APIs when targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "python3 -c \"import shutil; shutil.copyfile('src/app.ts','docs/bad.txt')\"", - "python3 -c \"import shutil; shutil.move('src/app.ts','docs/bad.txt')\"", - "ruby -e \"require 'fileutils'; FileUtils.cp('src/app.ts','docs/bad.txt')\"", - "perl -e \"use File::Copy; move('src/app.ts','docs/bad.txt')\"", - "perl -e \"unlink 'docs/bad.txt'\"", - "perl -e \"rename 'src/app.ts','docs/bad.txt'\"", - "perl -we \"unlink 'docs/bad.txt'\"", - "perl -we \"rename 'src/app.ts','docs/bad.txt'\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const payload = JSON.parse(result.stdout) as { - hookSpecificOutput?: { - hookEventName?: string; - permissionDecision?: string; - permissionDecisionReason?: string; - }; - }; - expect(payload.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); - expect(payload.hookSpecificOutput?.permissionDecision).toBe("deny"); - expect(payload.hookSpecificOutput?.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); - - it("denies inline file API aliases when exact targets cannot be parsed", () => { - writeHermesWorkerRecords([ - taskCard("job-active", "coder"), - heartbeat("job-active"), - ]); - - for (const command of [ - "python3 -c \"from shutil import copyfile as c; c('src/app.ts','docs/bad.txt')\"", - "node -e \"const { writeFileSync: w } = require('fs'); w('docs/bad.txt','x')\"", - "node -e \"const { writeFileSync: w } = require('node:fs'); w('docs/bad.txt','x')\"", - "node -e \"import { writeFileSync as w } from 'node:fs'; w('docs/bad.txt','x')\"", - ]) { - const result = runHermesRuntimeGuard(repoRoot, { - cwd: repoRoot, - hook_event_name: "PreToolUse", - agent_role: "coder", - tool_name: "Bash", - tool_input: { - job_id: "job-active", - command, - }, - session_id: "test-session", - }); - - expect(result.status).toBe(0); - const decision = parseDecision(result); - expect(decision.permissionDecision).toBe("deny"); - expect(decision.permissionDecisionReason).toContain( - "cannot safely parse Bash write targets", - ); - } - }); it("denies PreToolUse write tools when Hermes worker records are invalid", () => { const hermesDir = path.join( diff --git a/packages/cli/test/templates/trellis.test.ts b/packages/cli/test/templates/trellis.test.ts index bc4147f..2ae91b4 100644 --- a/packages/cli/test/templates/trellis.test.ts +++ b/packages/cli/test/templates/trellis.test.ts @@ -222,17 +222,15 @@ describe("trellis template constants", () => { expect(block).toMatch(/codex|copilot|gemini|qoder/); }); - it("[issue-237] workflow.md in_progress breadcrumb self-exempts implement/check sub-agents", () => { + it("[issue-237] workflow.md in_progress breadcrumb prevents recursive sub-agent dispatch", () => { // The in_progress breadcrumb may be injected into sub-agent turns on some // hosts, so its main-session dispatch guidance must not recursively apply // to a sub-agent that is already doing the requested work. const block = inProgressBreadcrumb(); expect(block).toContain("Main-session default"); expect(block).toContain("Sub-agent self-exemption"); - expect(block).toContain("already running as `trellis-implement`"); - expect(block).toContain("do NOT spawn another `trellis-implement`"); - expect(block).toContain("already running as `trellis-check`"); - expect(block).toContain("do NOT spawn another `trellis-check`"); + expect(block).toContain("an active sub-agent never dispatches another worker"); + expect(block).toContain("immediately close that subagent"); expect(block).toContain("main session only"); }); @@ -243,8 +241,7 @@ describe("trellis template constants", () => { expect(block).toContain("Review-gate contract: explicit-selection-v1"); expect(block).toContain("Optional review gates status: configured"); expect(block).toContain("trellis-check"); - expect(block).toContain("merge if needed"); - expect(block).toContain("build/test"); + expect(block).toContain("runner performs final validation/archive actions"); expect(block).toContain("trellis-code-architecture-review"); expect(block).toContain("does not by itself enable or block deep-review"); }); @@ -346,8 +343,9 @@ describe("trellis template constants", () => { "utf-8", ); expect(brainstorm).toContain("Do not enter `trellis-grill-me` only because a task is legacy or complex"); - expect(start).toContain("For a Lean Research Closure task, read the Task Capsule first"); - expect(start).toContain("legacy or explicitly complex Claude Code tasks"); + expect(start).toContain("For a Hermes closure task"); + expect(start).toContain("run `{{PYTHON_CMD}} ./.trellis/scripts/closure.py capsule --task ` first"); + expect(start).toContain("code-heavy complex tasks add `design.md` and `implement.md`"); }); it("workflow.md planning breadcrumb records Claude Code development strategy decisions before start", () => { @@ -433,9 +431,6 @@ describe("trellis template constants", () => { expect(config).toContain("approval_records"); expect(config).toContain('experiment: ".trellis/tasks/{task}/hermes/experiment.yaml"'); expect(config).toContain('run_manifest: ".trellis/tasks/{task}/hermes/run_manifest.jsonl"'); - expect(config).toContain("sandbox:"); - expect(config).toContain('mode: "none"'); - expect(config).toContain("required: false"); expect(config).toContain("heartbeat_beat"); expect(config).toContain("heartbeat_watch"); expect(config).toContain("runner_run"); @@ -448,8 +443,8 @@ describe("trellis template constants", () => { expect(config).toContain("service_enqueue"); expect(config).toContain("service_cancel"); expect(config).toContain("append-only"); - expect(config).toContain("deployment candidate hardening"); - expect(config).toContain("not an OS sandbox"); + expect(config).toContain("lightweight research collaboration"); + expect(config).toContain("runs directly in the project workspace"); expect(config).not.toContain("claim_allowed"); for (const state of [ @@ -530,7 +525,7 @@ describe("trellis template constants", () => { expect(recordBus).toContain("provenance"); expect(recordBus).toContain("audit"); expect(recordBus).toContain("service queue"); - expect(recordBus).toContain("not an OS sandbox"); + expect(recordBus).toContain("lightweight local collaboration tool"); expect(recordBus).toContain("report.md"); expect(recordBus).toContain("HumanGate"); diff --git a/packages/core/src/task/schema.ts b/packages/core/src/task/schema.ts index 3c31dd7..4b54695 100644 --- a/packages/core/src/task/schema.ts +++ b/packages/core/src/task/schema.ts @@ -86,6 +86,7 @@ export interface TrellisTaskRecord { research_route?: ResearchRoute; research_change_fields?: string[]; grill_completed?: boolean; + decision_ref?: string | null; constraints?: ClosureConstraints; work_packages?: ClosureWorkPackage[]; current_work_package?: string | null; @@ -137,6 +138,7 @@ export const TASK_RECORD_FIELD_ORDER = [ "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package", @@ -181,6 +183,7 @@ const NULLABLE_STRING_FIELDS: ReadonlySet = new Set([ "parent", "current_work_package", "next_action", + "decision_ref", ]); const STRING_ARRAY_FIELDS: ReadonlySet = new Set([ @@ -208,6 +211,7 @@ const OPTIONAL_CLOSURE_FIELDS: ReadonlySet = new Set([ "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package", @@ -438,6 +442,7 @@ export function emptyTaskRecord( research_route: "delivery", research_change_fields: [], grill_completed: false, + decision_ref: null, constraints: { excluded_platforms: [], excluded_paths: [], diff --git a/packages/core/test/task/schema.test.ts b/packages/core/test/task/schema.test.ts index 2234501..9dc9932 100644 --- a/packages/core/test/task/schema.test.ts +++ b/packages/core/test/task/schema.test.ts @@ -29,6 +29,7 @@ describe("emptyTaskRecord", () => { expect(record.research_route).toBe("delivery"); expect(record.research_change_fields).toEqual([]); expect(record.grill_completed).toBe(false); + expect(record.decision_ref).toBeNull(); expect(record.constraints).toEqual({ excluded_platforms: [], excluded_paths: [], @@ -121,6 +122,7 @@ describe("taskRecordSchema", () => { "research_route", "research_change_fields", "grill_completed", + "decision_ref", "constraints", "work_packages", "current_work_package", From bc36cf6e881a9f621e7672793ab897cae6e4b7a6 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Wed, 22 Jul 2026 23:59:33 +0800 Subject: [PATCH 06/13] fix(hermes): align workflow governance contracts --- docs/hermes_role_profile_migration.md | 2 +- ...es_trellis_research_transformation_plan.md | 12 +- docs/manual/09-experiments.md | 2 +- docs/manual/10-runs-and-replay.md | 2 +- docs/manual/13-workers-and-gates.md | 12 +- docs/manual/14-workflows-and-platforms.md | 8 +- docs/manual/15-channel-and-memory.md | 2 +- docs/manual/17-lean-research-closure.md | 6 +- docs/manual/README.md | 2 +- docs/manual/appendix-a-cli-reference.md | 4 +- docs/manual/appendix-b-directory-reference.md | 2 +- .../appendix-c-configuration-reference.md | 7 +- docs/manual/appendix-d-record-schema.md | 8 +- docs/manual/appendix-e-troubleshooting.md | 2 +- docs/manual/appendix-f-feature-status.md | 11 +- marketplace/workflows/native/workflow.md | 4 +- packages/cli/src/commands/update.ts | 29 +- packages/cli/src/configurators/codex.ts | 23 +- packages/cli/src/templates/codex/config.toml | 7 +- .../shared-hooks/hermes-runtime-guard.py | 368 +++++++++++++++-- .../shared-hooks/inject-workflow-state.py | 40 +- .../hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md | 18 +- .../templates/trellis/hermes/roles/base.md | 2 + .../templates/trellis/hermes/roles/coder.md | 5 +- .../trellis/hermes/roles/reviewer.md | 1 + .../templates/trellis/hermes/roles/runner.md | 2 +- .../trellis/scripts/common/closure.py | 5 + .../trellis/scripts/common/dispatch.py | 94 ++++- .../trellis/scripts/hermes/dispatch.py | 2 + .../trellis/scripts/hermes/runner.py | 51 ++- .../trellis/scripts/hermes/runtime.py | 176 +++++++- .../cli/src/templates/trellis/scripts/task.py | 18 - .../cli/src/templates/trellis/workflow.md | 4 +- packages/cli/src/utils/codex-config.ts | 382 ++++++++++++++++++ packages/cli/src/utils/codex-version.ts | 109 +++++ packages/cli/src/utils/uninstall-scrubbers.ts | 202 ++++++--- .../test/commands/update.integration.test.ts | 43 ++ .../cli/test/configurators/platforms.test.ts | 21 + ...agent-context-firewall.integration.test.ts | 32 +- .../test/scripts/closure.integration.test.ts | 8 +- .../test/templates/codex-packaging.test.ts | 50 +++ packages/cli/test/templates/codex.test.ts | 4 + .../cli/test/templates/hermes-runtime.test.ts | 2 + .../cli/test/templates/shared-hooks.test.ts | 322 ++++++++++++++- packages/cli/test/utils/codex-config.test.ts | 101 +++++ packages/cli/test/utils/codex-version.test.ts | 81 ++++ .../test/utils/uninstall-scrubbers.test.ts | 31 +- 47 files changed, 2085 insertions(+), 234 deletions(-) create mode 100644 packages/cli/src/utils/codex-config.ts create mode 100644 packages/cli/src/utils/codex-version.ts create mode 100644 packages/cli/test/templates/codex-packaging.test.ts create mode 100644 packages/cli/test/utils/codex-config.test.ts create mode 100644 packages/cli/test/utils/codex-version.test.ts diff --git a/docs/hermes_role_profile_migration.md b/docs/hermes_role_profile_migration.md index 8011409..7d35b42 100644 --- a/docs/hermes_role_profile_migration.md +++ b/docs/hermes_role_profile_migration.md @@ -26,7 +26,7 @@ ## 派发上下文 -`v0.7.1`(当前版本)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: +`v0.7.1-beta.0`(当前测试版)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: 1. `job_id`(工作编号)和 `hermes_revision`(Hermes 修订号); 2. 当前角色与模式; diff --git a/docs/hermes_trellis_research_transformation_plan.md b/docs/hermes_trellis_research_transformation_plan.md index 60ae88a..9afca82 100644 --- a/docs/hermes_trellis_research_transformation_plan.md +++ b/docs/hermes_trellis_research_transformation_plan.md @@ -62,13 +62,13 @@ ### P7-P11:deployment candidate hardening(部署候选加固) -当前 P7-P11 只做可验证薄切片:sandbox 配置 fail-closed、provenance -ledger、local service queue、audit ledger、evaluation quality gate。这里不能 -声称完整 `production ready`(生产就绪)或 OS 级生产平台。 +以下 P7-P11 是历史方案,不再作为当前路线。当前版本保留 provenance +ledger、audit ledger 和 evaluation quality gate 等轻量记录能力,不继续实现 +sandbox、容器、远程服务或复杂本地队列。 边界约定: -- `not an OS sandbox`(不是操作系统级沙箱)。`container` 模式已有 `Docker`(容器运行时)薄切片,会通过 `docker run --rm`(一次性容器执行命令)挂载仓库并运行白名单命令;它仍不是生产级强隔离。`external` 模式当前只做可用性检查。 +- `container`(容器)和 `external sandbox`(外部沙箱)方案已放弃;运行器直接在真实项目环境执行登记命令。 - `approval_records` 依赖 `external human/root approval`(外部人工/根权限批准)。 - `JSONL` 不是不可篡改存储,只是 append-only(追加写)约定。 - `allowed_commands` 不是 strong command sandbox(强命令沙箱);允许 `python3` 时仍可执行任意 Python 行为。 @@ -330,9 +330,9 @@ P7 Agent Context Firewall: - [x] 新增统一 `dispatch.py`(派发命令),支持创建、校验、显示、运行、列表、应用和状态查询。 - [x] 派发绑定单调 `hermes_revision`(Hermes 修订号)、当前工作包、五角色模式和最多 3 个引用。 - [x] Claude Agent 钩子只接受 `job_id`(工作编号),拒绝异步,并在结束时校验和净化结果。 -- [x] Codex native(Codex 原生)明确为建议性;strict(严格)复用结构化执行参数,不增加 SDK、数据库或服务。 +- [x] Codex native(Codex 原生)明确为建议性;旧 strict(严格)参数只保留兼容提示,不再提供独立执行模式。 - [x] 原始跟踪只写入忽略发布的 `.trellis/.runtime/`(本地运行目录);主上下文只接收净化摘要。 -- [x] `lean`(轻量)、`standard`(标准)和 `publication`(发表)按钩子或严格模式心跳执行不同门禁。 +- [x] `lean`(轻量)、`standard`(标准)和 `publication`(发表)共享钩子状态提示;模式差异由运行、证据、统计、主张和审批审计决定。 最终验证记录: diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index d4a5015..75b356e 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -8,7 +8,7 @@ 适用于本地脚本、测试、基准、数据处理和模型评估。纯文档任务仍会生成实验骨架,但启动前也必须把占位字段改成非空内容。 -数据预检不是通用步骤。只有任务属于 exploration(探索),且 `research_change_fields`(研究变化字段)包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时才启用;普通任务、冻结协议执行和不涉及这三个字段的探索任务都不增加预检。 +数据预检不是通用步骤。只有 Hermes closure(科研闭环)任务属于 exploration(探索),且 `research_change_fields`(研究变化字段)包含 `dataset`(数据集)、`split`(数据切分)或 `preprocessing`(预处理)时才启用;旧版非 closure 任务、普通任务、冻结协议执行和不涉及这三个字段的探索任务都不增加预检。 ## 前置条件 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index 2d094cb..fb60377 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -32,7 +32,7 @@ python3 ./.trellis/scripts/hermes/record.py append --task "$TASK" --record-type python3 ./.trellis/scripts/hermes/runner.py run --task "$TASK" --job-id "$JOB" --checkpoint evaluate --summary "运行固定数据评估" --input fixtures/input.jsonl --input scripts/evaluate.py --output results/metrics.json -- python3 scripts/evaluate.py ``` -运行器会写入检查点、心跳、标准输出日志、标准错误日志、运行清单,以及成功结果或失败拒绝记录。 +运行器会在命令启动前记录声明输入的路径、大小和 `SHA-256`(文件摘要),随后写入检查点、心跳、标准输出日志、标准错误日志、运行清单,以及成功结果或失败拒绝记录。即使命令会修改某个输入,清单保留的也是实际启动时读取到的版本。 3. 校验工作代理记录和运行清单: diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index ce24167..80c57e8 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -81,7 +81,7 @@ profile: evidence ### 派发与结果防火墙 -1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包;任务级 `planner`(规划代理)和 `reviewer`(复核代理)可不绑定。 +1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)还必须用 `parent_job_id`(父工作编号)绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 ```bash JOB="job-$(date -u +%Y%m%d-%H%M%S)-coder" @@ -94,7 +94,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py validate --task "$TASK" --job-id " 2. 派发正文最多 2000 字符、直接引用最多 3 个。绝对用户路径、敏感信息、越界或不存在的引用会被拒绝。任务状态变化后,旧派发会返回 `stale_dispatch`(过期派发)。 -3. `Claude Code`(Claude 代码工具)的 `Agent`(代理)调用只传 `job_id`(工作编号),钩子会机械替换长提示,并禁止异步派发。`Codex`(代码代理平台)的 native(原生)代理仅为 advisory(建议性);需要硬门禁时运行: +3. `Claude Code`(Claude 代码工具)的 `Agent`(代理)调用只传 `job_id`(工作编号),钩子会机械替换长提示,并禁止异步派发。`Codex`(代码代理平台)的派发输入依赖同一协议和结构化文件,属于 advisory(建议性)约束;下面的命令只显示经过校验的紧凑派发,不会创建沙箱或升级成严格执行器: ```bash python3 ./.trellis/scripts/hermes/dispatch.py run --task "$TASK" \ @@ -117,16 +117,18 @@ python3 ./.trellis/scripts/hermes/jobs.py check --task "$TASK" python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" ``` +测试、构建、实验和验证命令由 `runner`(运行代理)通过 `runner.py run`(登记运行)执行。直接运行 `pnpm test`(运行测试)等命令不会经过运行清单,因此在正式角色门禁下会被拒绝。 + 6. 当前工作代理校验还会检查: - 同一工作树不能同时存在多个未结束的 `coder`(编码代理)或 `runner`(运行代理); - 结果中的改动文件必须落在任务卡允许范围内,且不能命中禁止范围; - 配置了心跳间隔的结果需要已有检查点; - `runner`(运行代理)成功只接受已有 `run_refs`(运行引用),不能把成功输出写成证据; -- `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录; +- `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录;需要独立复核的记录必须显式绑定被审工作,旧记录只在同一工作包只有一个候选时兼容; - 经 `Dispatch Packet`(派发包)确认的编码结果进入 `review`(复核)或 `claim_ready`(待审批)前,需要 `runner:test/build/validation`(运行代理的测试、构建或验证模式)结果,以及 `reviewer:quality/safety`(复核代理的质量或安全模式)记录。证据、主张或关闭复核不能替代代码质量复核。这个派发结果门禁不替代 `package-check`(工作包复核)或 `close`(关闭)的各自收口审计;未经过正式派发的旧任务继续按兼容流程处理。 -7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。当前内置完整运行时门禁只在 `Claude Code`(Claude 代码工具)和已启用钩子的 `Codex`(代码代理)配置中注册。 +7. 在支持并启用 `Hermes`(科研工作流)运行时钩子的平台中,`PreToolUse`(工具使用前)门禁会限制主代理直接写入和执行,并按任务卡检查工作代理目标文件;`Stop`(结束)门禁读取记录、运行清单和当前文件差异,不会在结束时重新运行测试。Claude 可额外替换 Agent 派发输入并净化返回;Codex 当前只对写入、受控命令和结束条件做机械检查,不能声明为完整输入隔离。两者都不是操作系统沙箱。 `Stop`(结束)按实际工作类型检查:存在实现代码改动时要求编码结果、成功运行清单和独立质量复核;没有代码改动的实验、检索或规划工作要求对应角色结果和独立复核,正式运行还必须引用成功的运行清单。它不会为了纯研究任务强制创建编码结果。 @@ -176,7 +178,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 7130ea7..93a4c54 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -67,11 +67,13 @@ trust_level = "trusted" 重启 `Codex`(代码代理)后运行 `/hooks`(钩子审阅),逐项检查并批准 `.codex/hooks.json`(Codex 钩子配置)中的工作流注入和 `Hermes`(科研工作流)门禁。旧版 `Codex`(代码代理)使用 `codex_hooks = true`(旧钩子开关)。项目未信任、功能开关未开、未经 `/hooks`(钩子审阅)批准,或设置 `TRELLIS_HOOKS=0`(关闭钩子)、`TRELLIS_DISABLE_HOOKS=1`(禁用钩子)时,注入和门禁都不生效。 -`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会把 Hermes 正式角色升级为硬门禁。 +项目模板为 `Codex 0.144.0`(代码代理 0.144.0 版)及以上版本设置默认模型:主代理以及 `planner/researcher/reviewer`(规划、检索和复核代理)使用 `gpt-5.6-sol`(推理模型)和 `high`(高推理强度),`coder/runner`(编码和运行代理)使用 `gpt-5.6-terra`(执行模型)和 `max`(最大推理强度)。初始化和更新只维护带 Trellis 标记的配置块;用户自行选择的项目模型会保留,卸载时也不会被当成 Trellis 默认值删除。旧版 `Codex`(代码代理)只显示兼容警告,不阻止初始化。 + +`codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 `v0.7.1-beta.0`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 -五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,直接在当前项目工作区协作。关闭条件仍由任务记录、证据和审计决定。 +五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 ## 用户入口 @@ -96,7 +98,7 @@ trust_level = "trusted" | `Claude Code`(Claude 代码工具) | `SessionStart + UserPromptSubmit`(会话开始和用户提交) | 正式 Hermes Agent 只传 `job_id`(工作编号),钩子替换提示并净化结果 | 完整 | 需平台读取项目设置;异步 Hermes Agent 被拒绝;环境开关可关闭 | | `Cursor`(Cursor 编辑器) | `sessionStart`(会话开始)注入 capsule 和命令行上下文 | 支持;`Task / Subagent`(子代理工具)前推送 | 无 | 没有每轮 capsule;只有平台实际触发 `.cursor/hooks.json`(Cursor 钩子)时才注入 | | `OpenCode`(开放代码工具) | `chat.message`(会话消息)注入会话摘要、每轮状态和 capsule | 支持;代理按派发首行自行读取 | 无 | 依赖 OpenCode 插件被实际加载;会话检索功能仍未实现 | -| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 使用紧凑派发与结果文件,直接在当前工作区执行 | 任务状态、证据和关闭审计 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅) | +| `Codex`(代码代理) | `UserPromptSubmit`(用户提交),无会话开始注入 | 使用紧凑派发与结果文件,直接在当前工作区执行 | 写入、登记命令和结束条件 | 钩子需用户级开关、项目信任和 `/hooks`(钩子审阅);派发输入仍为协议约束 | | `Kilo CLI`(Kilo 命令行) | 手动运行 capsule | 无,主会话执行模板 | 无 | 依赖工作流文件和手动读取 | | `Kiro Code`(Kiro 代码工具) | 无通用主会话注入 | 支持;`agentSpawn`(代理创建)时推送 | 无 | 只注册代理创建钩子,无会话开始和每轮主会话钩子 | | `Gemini CLI`(Gemini 命令行) | `SessionStart + BeforeAgent`(会话开始和代理前) | 支持;代理前置说明拉取 | 无 | 依赖 `.gemini/settings.json`(Gemini 设置)的当前钩子协议 | diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index e580b59..5840d3b 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "OpenCode|channel|mem" packages/cli/src/commands packages/core/src packages/cli/test packages/core/test`(频道与会话读取核对)。 - 结果:频道、会话读取和 `OpenCode`(开放代码工具)未实现分支均有当前代码与测试依据。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 8a6ac15..fc5efc0 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" `run finished`(运行结束)、`package done`(工作包完成)、`task closed`(任务关闭)和 `claim approved`(主张批准)是不同状态。运行成功只生成运行记录,不能直接成为已接受证据;任务关闭也不能代替论文主张批准。 -模式还会检查防火墙可用性:`lean`(轻量)在没有新鲜钩子或严格模式心跳时强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发和关闭。 +模式都会检查钩子心跳;缺少新鲜钩子时给出明确警告,并继续使用派发、结果、记录和审计协议。`standard`(标准)与 `publication`(发表)的额外关闭条件来自运行清单、证据、统计、主张和人工审批,不来自沙箱或已取消的 Codex 严格执行器。 ## Audit 与有限修复 @@ -157,7 +157,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ `HANDOFF.md` 只在代理切换、压缩或清理上下文前、任务阻塞、显式调用和关闭时需要。它记录当前目标、阶段、工作包、已完成结果、改动文件、证据、失败尝试、阻塞和下一动作,但不会修改任务、证据或主张状态。 -有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。暂停后恢复、切换代理或压缩上下文时,提示会给出 `HANDOFF.md`(交接摘要)路径;任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理。 +有活动 closure 任务时,主代理的每次用户请求都会收到一条紧凑续接提示:当前阶段、当前工作包、下一动作和 `task.json`(任务状态)路径。新生成的交接会记录任务修订号;只有修订号仍匹配时,恢复提示才会给出 `HANDOFF.md`(交接摘要)路径。旧格式或过期交接只显示警告,任务状态仍以 `task.json` 为准。交接文件全文不会自动注入,也不会默认传给子代理;`task.py finish`(结束会话)只清除会话指针,不会隐式生成交接。 上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 @@ -186,7 +186,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index 0bef677..f58667e 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -30,7 +30,7 @@ python3 ./.trellis/scripts/task.py list ## 使用边界 - **已实现**:初始化、更新、任务与规范文件、多个平台模板、科研记录脚本、实验运行清单、比较、报告、频道和会话检索命令均有当前代码与测试依据。 -- **实验性**:整个科研改造版仍是测试版;`Hermes`(科研工作流)门禁、代理协作、频道工作代理和容器运行都应先在低风险项目试用。 +- **实验性**:整个科研改造版仍是测试版;`Hermes`(科研工作流)门禁、代理协作和频道工作代理都应先在低风险项目试用。当前版本直接在真实项目环境运行,不提供容器或沙箱执行模式。 - **未实现**:操作系统级安全沙箱、防篡改记录库、人工审批界面、远程任务服务,以及当前版本的 `OpenCode`(开放代码工具)会话读取器。 手册只描述当前正式仓库中的代码、命令、模板和测试。各页的“验证记录”说明本次实际执行过的检查,“来源”统一指向[事实来源](sources.md)。 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index e006a01..85abe23 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1`(测试版)。 +适用于 `0.7.1-beta.0`(测试版)。 ## 前置条件 @@ -115,7 +115,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id /hermes/`(任务科研记录)保存任务事实。 - `task.json`(任务状态)保存当前 closure 状态;`task-events.jsonl`(任务事件)只追加重要历史。 -- `HANDOFF.md`(交接)按条件生成;`closure-report.md`(关闭报告)由通过的收口审计生成。 +- `HANDOFF.md`(交接)按条件生成并记录对应任务修订号;旧格式或过期交接只用于恢复参考。`closure-report.md`(关闭报告)由通过的收口审计生成。 - `.trellis/.runtime/`(会话运行状态)保存活动任务指针,通常不提交。 - `.codex/`(Codex 配置)、`.claude/`(Claude 配置)等目录由平台选择决定。 diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index 30458b8..a150988 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -31,14 +31,13 @@ | 环境变量 | 含义 | | --- | --- | -| `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;`lean`(轻量)强警告,`standard`(标准)和 `publication`(发表)拒绝缺少强门禁的操作 | +| `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;派发和关闭时提示当前处于协议约束模式 | | `TRELLIS_DISABLE_HOOKS=1`(禁用钩子) | 与上一项相同,用于兼容现有启动方式 | | `TRELLIS_HOOKS_ACTIVE=1`(钩子活动标记) | 仅作状态提示;必须同时存在签名且新鲜的心跳,不能单独通过门禁 | | `TRELLIS_PLATFORM`(平台) | 仅接受 `claude`(Claude)或 `codex`(Codex)作为正式防火墙平台 | -| `TRELLIS_CODEX_STRICT=1`(Codex 严格标记) | 仅作状态提示;严格包装仍需实际隔离执行和签名心跳,手工设置无效 | | `TRELLIS_CONTEXT_FIREWALL_HEARTBEAT_TTL`(心跳有效期) | 新鲜心跳的秒数,默认 900 | -`SessionStart`(会话启动)、运行守卫和 Codex 严格包装会在 `.trellis/.runtime/context-firewall/`(防火墙运行目录)写入心跳。`lean`(轻量)缺少新鲜心跳时继续但强警告;`standard`(标准)必须有钩子或严格执行;`publication`(发表)缺少硬门禁时拒绝派发和关闭。原始跟踪只写入 `.trellis/.runtime/hermes-traces/`(原始跟踪目录),默认不进入模板发布包。 +会话和运行守卫会在 `.trellis/.runtime/context-firewall/`(防火墙运行目录)写入签名心跳。缺少新鲜心跳时工具会警告,三种 closure 模式仍以结构化派发、结果、运行清单、证据和审计记录判断是否完成。原始跟踪只写入 `.trellis/.runtime/hermes-traces/`(原始跟踪目录),默认不进入模板发布包。 `.trellis/tasks//hermes/experiment.yaml`(任务实验配置)定义问题、假设、数据、指标、随机种子、环境、允许命令、隔离模式和产物目录。 @@ -59,7 +58,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|session_auto_commit|allowed_commands" packages/cli/src/templates/trellis`(配置模板核对)。 - 结果:会话、Hermes 防火墙、`Codex`(代码代理)调度和实验命令配置均可定位,模式门禁与心跳设置已对齐。 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index bda7f7c..4da8a64 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -16,7 +16,7 @@ | 类型 | 必填字段摘要 | | --- | --- | -| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags`;`profile`(模式)可省略,写入时会补默认值 | +| `task_card`(任务卡) | `type,id,timestamp,job_id,role,worktree_id,status,allowed_files,forbidden_files,heartbeat_interval,timeout_at,checkpoint,resume_from,record_uri,evidence_refs,risk_flags`;`profile`(模式)可省略,正式质量、证据、主张和统计复核还要有 `parent_job_id`(父工作编号) | | `heartbeat`(心跳) | `type,id,timestamp,job_id,status,checkpoint,summary,next_check_at` | | `checkpoint`(检查点) | `type,id,timestamp,job_id,checkpoint,resume_from,evidence_refs,open_items` | | `result`(结果) | `type,id,timestamp,job_id,status,summary,changed_files,evidence_refs,risk_flags,handoff` | @@ -32,8 +32,8 @@ | 文件 | 关键字段 | | --- | --- | -| `.dispatch.json`(派发文件) | `schema_version,job_id,task_id,hermes_revision,role,profile,work_package,objective,refs,allowed_files,forbidden_files,status,body,audit` | -| `.result.json`(净化结果) | `schema_version,job_id,status,conclusion,uncertainties,changed_files,evidence_refs,artifact_refs,run_refs,risk_flags,next_action,audit` | +| `.dispatch.json`(派发文件) | `schema,schema_version,job_id,task_id,task_revision,hermes_revision,role,profile,work_package,parent_job_id,objective,refs,allowed_files,forbidden_files,status,body,audit` | +| `.result.json`(净化结果) | `schema,schema_version,job_id,task_revision,role,profile,parent_job_id,status,conclusion,uncertainties,changed_files,evidence_refs,artifact_refs,run_refs,risk_flags,next_action,audit` | 派发最多 3 个引用,正文最多 2000 字符;结果结论最多 1200 字符。`runner`(运行代理)结果的 `evidence_refs`(证据引用)必须为空,`reviewer:evidence/claim`(证据或主张复核)只能写 proposed judgment(提议判断)。raw trace(原始跟踪)位于 `.trellis/.runtime/hermes-traces/`(本地运行目录),不属于任务记录或发布包。 @@ -60,7 +60,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "REQUIRED_FIELDS|PLAN_CHANGE_DECISION_STATES" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(记录结构核对)。 - 结果:必填字段表和计划变更决定值与运行时定义一致;`recordbus.md`(记录总线说明)已标为全局协议路径。 diff --git a/docs/manual/appendix-e-troubleshooting.md b/docs/manual/appendix-e-troubleshooting.md index 5bda22c..15ef918 100644 --- a/docs/manual/appendix-e-troubleshooting.md +++ b/docs/manual/appendix-e-troubleshooting.md @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality gate|missing evidence|approval" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(失败关闭分支核对)。 - 结果:质量门禁、缺失证据和审批失败分支都有实现或测试依据。 diff --git a/docs/manual/appendix-f-feature-status.md b/docs/manual/appendix-f-feature-status.md index bbd2105..91c0205 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1`(测试版),后续版本应重新核对。 +适用于 `0.7.1-beta.0`(测试版),后续版本应重新核对。 ## 前置条件 @@ -22,7 +22,7 @@ | Lean Research Closure | 已实现、实验性 | 默认 1–4 个工作包;自动候选仍需人工审阅 | | Task Capsule 与条件式 Handoff | 已实现 | 平台没有主会话 hook 时需手动调用 | | 五角色与 Profile | 已实现、实验性 | 正式平台代理模板当前只提供 Claude Code 和 Codex;旧任务卡可兼容读取 | -| Agent Context Firewall | 已实现、实验性 | 正式支持 Claude Code 和 Codex;Claude 依赖已批准钩子,Codex native 仅建议、strict 才强制 | +| Agent Context Firewall | 已实现、实验性 | Claude 支持派发替换与结果净化;Codex 支持写入、命令和结束门禁,子代理输入仍依赖协议 | | 结构化 Result Envelope | 已实现 | 拒绝日志、完整差异、搜索过程和敏感信息;原始跟踪只保存在忽略发布的本地运行目录 | | `hermes_revision`(Hermes 修订号) | 已实现 | Closure 语义写入原子递增;执行前拒绝过期派发 | | 确定性证据整理 | 已实现 | 校验和汇总引用,不判断证据质量或批准主张 | @@ -30,8 +30,7 @@ | 科研记录与结构校验 | 已实现、实验性 | 追加式约定,不防篡改 | | 科研状态流程 | 协议模板 | `state_machine.yaml`(状态协议)未作为通用运行时迁移引擎强制执行 | | 实验运行与运行清单 | 已实现、实验性 | 默认无隔离,允许命令不是强沙箱 | -| 容器运行 | 实验性 | 依赖本机 `docker`(容器命令)和镜像 | -| 外部沙箱运行 | 未实现 | 配置可解析,当前运行器拒绝执行 | +| 容器或外部沙箱运行 | 不提供 | 当前版本直接在真实项目环境运行,不把流程门禁描述为隔离环境 | | 比较命令 | 已实现但有限制 | 不写统计字段,无法直接通过质量门禁 | | 报告和主张审查 | 已实现 | 报告保持待审批,不自动批准 | | 人工审批界面与身份认证 | 未实现 | 只校验记录字段,真实性靠外部流程 | @@ -47,7 +46,7 @@ - `allowed_commands`(允许命令)和代理文件范围用于流程约束,不是操作系统级沙箱。 - `RecordBus`(记录总线)使用本地 `JSONL`(逐行 JSON)文件,便于审计,但不能防止有文件权限的进程篡改历史。 - 平台钩子可以未启用、被环境变量关闭或因平台差异失效。执行危险命令前仍需人工确认。 -- `lean`(轻量)缺少防火墙时只强警告;`standard`(标准)要求钩子或严格模式;`publication`(发表)缺少硬门禁时拒绝派发与关闭。 +- 缺少新鲜钩子时三种 closure 模式都会提示降级;`standard`(标准)和 `publication`(发表)仍通过更严格的运行、证据、统计、主张和人工审批记录完成审计。 - 不要把 `.env`(环境变量文件)、访问令牌、密码、私有数据或未脱敏实验输入写入任务、记录和报告,也不要提交到 `Git`(版本控制系统)。 - 外部文献、数据、模型和网络服务需要单独核对许可证、隐私和访问权限。 @@ -70,7 +69,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1`(测试版)。 +- 版本:`0.7.1-beta.0`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node packages/cli/scripts/release-preflight.js check-licenses`(许可证元数据与权利说明一致性检查);`rg -n -m 1 "COPYRIGHT|LICENSE|files" packages/cli/package.json packages/core/package.json packages/cli/scripts/release-preflight.js`(打包权利文件核对)。 - 结果:项目和两个包的 `SPDX`(许可证标识)均为 `AGPL-3.0-or-later`(AGPL 第 3 版或任何后续版本);两个包均声明 `LICENSE`(许可证)和 `COPYRIGHT`(版权声明),发布预检也将其列为必需文件。 diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 2582655..b532462 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -288,8 +288,8 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, or blocking. At closure, reviewer checks closure and runner performs final validation/archive actions. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Quality, evidence, claim, and statistics reviews bind `parent_job_id` to the exact job under review; closure and independent safety reviews may remain task-level. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Runner commands go through `runner.py` so the run manifest records the actual execution. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 2e265b5..88f9a42 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -33,6 +33,11 @@ import { compareVersions } from "../utils/compare-versions.js"; import { toPosix } from "../utils/posix.js"; import { setupProxy } from "../utils/proxy.js"; import { emptyTaskJson } from "../utils/task-json.js"; +import { + isTrellisCodexConfigMerge, + mergeTrellisCodexConfig, +} from "../utils/codex-config.js"; +import { warnCodexVersion } from "../utils/codex-version.js"; // Import templates for comparison import { @@ -687,6 +692,21 @@ function collectTemplateFiles( } } + const codexConfigPath = ".codex/config.toml"; + const codexConfig = files.get(codexConfigPath); + const existingCodexConfigPath = path.join(cwd, codexConfigPath); + if (codexConfig && fs.existsSync(existingCodexConfigPath)) { + try { + const existing = fs.readFileSync(existingCodexConfigPath, "utf-8"); + files.set( + codexConfigPath, + mergeTrellisCodexConfig(existing, codexConfig).content, + ); + } catch { + // The normal update conflict path handles unreadable project config. + } + } + // A pre-existing root CLAUDE.md has no Trellis write receipt. Keep it // outside update ownership even under --force. const rootClaudePath = "CLAUDE.md"; @@ -818,10 +838,14 @@ function analyzeChanges( const storedHash = hashes[relativePath]; const currentHash = computeHash(existingContent); + const safeCodexConfigMerge = + relativePath === ".codex/config.toml" && + isTrellisCodexConfigMerge(existingContent, newContent); if ( (storedHash && storedHash === currentHash) || (!storedHash && - isKnownUntrackedTemplate(relativePath, existingContent)) + isKnownUntrackedTemplate(relativePath, existingContent)) || + safeCodexConfigMerge ) { // Either the tracked hash matches, or this is a known pristine template // from before the path was hash-tracked. Safe to auto-update. @@ -1882,6 +1906,9 @@ export async function update(options: UpdateOptions): Promise { // the prune step would otherwise consider orphans (codex hasn't been added // to configuredPlatforms yet at this point). const codexUpgradeNeeded = needsCodexUpgrade(cwd); + if (codexUpgradeNeeded || getConfiguredPlatforms(cwd).has("codex")) { + warnCodexVersion(); + } if (codexUpgradeNeeded) { console.log( chalk.yellow( diff --git a/packages/cli/src/configurators/codex.ts b/packages/cli/src/configurators/codex.ts index e5d941e..512034e 100644 --- a/packages/cli/src/configurators/codex.ts +++ b/packages/cli/src/configurators/codex.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { AI_TOOLS } from "../types/ai-tools.js"; import { @@ -8,6 +9,8 @@ import { getHooksConfig, } from "../templates/codex/index.js"; import { ensureDir, writeFile } from "../utils/file-writer.js"; +import { mergeTrellisCodexConfig } from "../utils/codex-config.js"; +import { warnCodexVersion } from "../utils/codex-version.js"; import { resolvePlaceholders, resolveAllAsSkillsNeutral, @@ -26,6 +29,7 @@ import { * - .codex/agents/, hooks/, hooks.json, config.toml — platform-specific */ export async function configureCodex(cwd: string): Promise { + warnCodexVersion(); // Shared skills from common source → .agents/skills/ // Uses the neutral placeholder resolver so shared workflow skills render to // the same bytes regardless of which platform writes them — required because @@ -132,8 +136,19 @@ export async function configureCodex(cwd: string): Promise { // Config → .codex/config.toml const config = getConfigTemplate(); - await writeFile( - path.join(codexRoot, config.targetPath), - replacePythonCommandLiterals(config.content), - ); + const configPath = path.join(codexRoot, config.targetPath); + const templateContent = replacePythonCommandLiterals(config.content); + let configContent = templateContent; + if (fs.existsSync(configPath)) { + try { + const existing = fs.readFileSync(configPath, "utf-8"); + configContent = mergeTrellisCodexConfig( + existing, + templateContent, + ).content; + } catch { + // Keep writeFile's existing-file handling for an unreadable config. + } + } + await writeFile(configPath, configContent); } diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index ba7b36c..4e123bc 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -1,3 +1,4 @@ +# TRELLIS:CODEX_CONFIG:START # Project-scoped Codex defaults for Trellis workflows. # Codex merges this layer after the user-level config when the project # is marked as a trusted project. To trust this project, add it under @@ -8,9 +9,12 @@ # # Without trust, the [features] block below is loaded but disabled. -# Default coordinator model for this research workflow. +# Default coordinator model for this research workflow. Codex 0.144.0 or +# newer is required to recognize these model and reasoning-effort values. +# TRELLIS:CODEX_MODEL_DEFAULTS:START model = "gpt-5.6-sol" model_reasoning_effort = "high" +# TRELLIS:CODEX_MODEL_DEFAULTS:END # Keep AGENTS.md as the primary project instruction file. project_doc_fallback_filenames = ["AGENTS.md"] @@ -41,3 +45,4 @@ min_wait_timeout_ms = 120000 # Envelope files. It works directly in the current project workspace. [hermes.context_firewall] native_authority = "protocol" +# TRELLIS:CODEX_CONFIG:END diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index f2afb4b..0b04a8e 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -414,38 +414,205 @@ def is_main_agent_request(data: dict[str, Any]) -> bool: def is_main_bash_allowed(command: str) -> bool: - """Allow only simple coordinator inspection and Hermes control commands. + """Classify the small coordinator command surface without parsing shell. - This is intentionally a small prefix check, not a shell parser. Hermes uses - role routing and task records for coordination; it does not try to infer - every effect of arbitrary scripts. + This is deliberately an explicit command matrix, not a shell-security + parser. Any shell composition is rejected before the command family is + checked. Main agents use Read for records; Bash is only for deterministic + task/closure control and narrowly scoped Git inspection. """ normalized = command.strip().replace("\\", "/") - # This is a coordinator convenience allowlist, not a shell-security - # parser. Compound or expanded commands are never control-plane commands. - if not normalized or any(marker in normalized for marker in (";", "&", "|", ">", "<", "`", "$", "\n")): + if not normalized or any( + marker in normalized + for marker in (";", "&", "|", ">", "<", "`", "$", "\n", "\r") + ): return False - prefixes = ( - "git status", - "git diff", - "git log", - "cat .trellis/tasks/", - "cat .ai/records/", - "jq ", - "python ./.trellis/scripts/task.py ", - "python3 ./.trellis/scripts/task.py ", - "python .trellis/scripts/task.py ", - "python3 .trellis/scripts/task.py ", - "python ./.trellis/scripts/closure.py audit ", - "python3 ./.trellis/scripts/closure.py audit ", - "python .trellis/scripts/closure.py audit ", - "python3 .trellis/scripts/closure.py audit ", - "python ./.trellis/scripts/hermes/dispatch.py ", - "python3 ./.trellis/scripts/hermes/dispatch.py ", - "python .trellis/scripts/hermes/dispatch.py ", - "python3 .trellis/scripts/hermes/dispatch.py ", + tokens = normalized.split() + if not tokens: + return False + if tokens[0] == "git": + return is_main_git_read_allowed(tokens) + if tokens[0] not in {"python", "python3"} or len(tokens) < 3: + return False + + script = tokens[1] + while script.startswith("./"): + script = script[2:] + subcommand = tokens[2] + arguments = tokens[3:] + if script == ".trellis/scripts/task.py": + return is_main_task_command_allowed(subcommand, arguments) + if script == ".trellis/scripts/hermes/dispatch.py": + return subcommand in { + "create", + "validate", + "show", + "run", + "apply", + "list", + "status", + "supersede", + } + if script == ".trellis/scripts/closure.py": + return subcommand in { + "plan", + "route", + "grill", + "validate", + "status", + "next", + "capsule", + "package-start", + "package-check", + "package-done", + "package-block", + "amend", + "repair", + "audit", + "close", + } + return False + + +def is_main_task_command_allowed(subcommand: str, arguments: list[str]) -> bool: + """Allow deterministic task coordination, never archive auto-commit.""" + coordination_commands = { + "create", + "add-context", + "validate", + "list-context", + "start", + "current", + "finish", + "set-branch", + "set-base-branch", + "set-scope", + "list", + "list-archive", + "add-subtask", + "remove-subtask", + } + if subcommand in coordination_commands: + return True + # task.py archive can stage and commit unless this explicit no-write flag + # is present. Main may advance the task lifecycle, not write Git history. + return subcommand == "archive" and "--no-commit" in arguments + + +def is_main_git_read_allowed(tokens: list[str]) -> bool: + """Allow a small, non-writing Git inspection matrix. + + Git accepts many diff options and pathspec forms. We do not try to parse + them generally: only the read forms below are coordinator commands. + """ + if len(tokens) < 2: + return False + command = tokens[1] + arguments = tokens[2:] + if any( + value == "--output" + or value.startswith("--output=") + or value in {"--ext-diff", "--no-index"} + or value.startswith("--ext-diff=") + or value.startswith("--no-index=") + for value in arguments + ): + return False + if command == "branch": + return arguments == ["--show-current"] + if command == "status": + allowed_options = { + "--short", + "-s", + "--branch", + "-b", + "--porcelain", + "--porcelain=v1", + "--porcelain=v2", + "--untracked-files=no", + "--untracked-files=normal", + "--untracked-files=all", + } + if "--" not in arguments: + return all(value in allowed_options for value in arguments) + separator = arguments.index("--") + options = arguments[:separator] + pathspecs = arguments[separator + 1:] + return ( + bool(pathspecs) + and all(value in allowed_options for value in options) + and all(is_safe_main_git_pathspec(value) for value in pathspecs) + ) + if command == "diff": + return is_main_git_diff_allowed(arguments) + if command == "log": + return is_main_git_log_allowed(arguments) + return False + + +def is_main_git_diff_allowed(arguments: list[str]) -> bool: + allowed_options = { + "--name-only", + "--name-status", + "--stat", + "--summary", + "--check", + "--cached", + "--staged", + "--no-ext-diff", + "--no-textconv", + } + if not arguments: + return True + if "--" not in arguments: + return all(value in allowed_options for value in arguments) + separator = arguments.index("--") + options = arguments[:separator] + pathspecs = arguments[separator + 1:] + return ( + bool(pathspecs) + and all(value in allowed_options for value in options) + and all(is_safe_main_git_pathspec(value) for value in pathspecs) + ) + + +def is_main_git_log_allowed(arguments: list[str]) -> bool: + allowed_options = { + "--oneline", + "--decorate", + "--no-decorate", + "--stat", + "--name-only", + "--name-status", + "--no-ext-diff", + "--no-textconv", + } + for value in arguments: + if value == "--": + return False + if value in allowed_options or value.startswith("--max-count="): + continue + if value.startswith("-n") and value[2:].isdigit(): + continue + return False + return True + + +def is_safe_main_git_pathspec(value: str) -> bool: + normalized = value.replace("\\", "/").strip() + if ( + not normalized + or normalized.startswith(("/", "../", "./../", ":")) + or ".." in Path(normalized).parts + ): + return False + name = Path(normalized).name.casefold() + return not ( + name.startswith(".env") + or "credential" in name + or "secret" in name + or name in {"id_rsa", "id_ed25519"} ) - return normalized.startswith(prefixes) def main_agent_firewall_reason(data: dict[str, Any]) -> str | None: @@ -470,6 +637,44 @@ def main_agent_firewall_reason(data: dict[str, Any]) -> str | None: ) +def declared_worker_role(data: dict[str, Any]) -> str | None: + aliases = { + "builder": "coder", + "coder": "coder", + "hermes_coder": "coder", + "trellis_implement": "coder", + "runner": "runner", + "hermes_runner": "runner", + } + for value in agent_identity_values(data): + normalized = normalized_agent_identity(value) + if normalized in aliases: + return aliases[normalized] + return None + + +def is_worker_bash_command_allowed(role: str, command: str) -> bool: + """Allow role-specific commands without attempting to parse a shell.""" + normalized = command.strip().replace("\\", "/") + if not normalized or any( + marker in normalized + for marker in (";", "&", "|", ">", "<", "`", "$", "\n", "\r") + ): + return False + tokens = normalized.split() + if role == "coder": + return bool(tokens) and tokens[0] == "git" and is_main_git_read_allowed(tokens) + if role != "runner" or len(tokens) < 3 or tokens[0] not in {"python", "python3"}: + return False + script = tokens[1] + while script.startswith("./"): + script = script[2:] + return ( + script == ".trellis/scripts/hermes/runner.py" + and tokens[2] in {"run", "replay", "validate"} + ) + + def no_active_task_tool_denial(tool_name: str) -> str: return ( "Hermes Runtime: no active task is selected; denying " @@ -601,6 +806,65 @@ def changed_files_from_record(record: dict[str, Any]) -> list[str]: return [item for item in changed if isinstance(item, str) and item.strip()] +def _parent_job_value(record: dict[str, Any]) -> tuple[bool, str | None]: + if "parent_job_id" not in record: + return False, None + value = record.get("parent_job_id") + if value is None: + return True, None + if isinstance(value, str) and value.strip(): + return True, value.strip() + return True, "" + + +def _legacy_parent_candidates( + cards: dict[str, dict[str, Any]], + child_job_id: str, + work_package: Any, +) -> set[str]: + return { + candidate_job_id + for candidate_job_id, candidate in cards.items() + if candidate_job_id != child_job_id + and candidate.get("role") != "reviewer" + and candidate.get("work_package") == work_package + } + + +def record_matches_parent( + record: dict[str, Any], + cards: dict[str, dict[str, Any]], + parent_job_id: str, +) -> bool: + child_job_id = record_job_id(record) + if child_job_id is None: + return False + card = cards.get(child_job_id) + parent_card = cards.get(parent_job_id) + if card is None or parent_card is None: + return False + explicit_card, card_parent = _parent_job_value(card) + explicit_record, record_parent = _parent_job_value(record) + if explicit_card: + return ( + card_parent == parent_job_id + and (not explicit_record or record_parent == card_parent) + and parent_card.get("work_package") == card.get("work_package") + ) + if explicit_record: + return ( + record_parent == parent_job_id + and parent_card.get("work_package") == card.get("work_package") + ) + # Legacy records can only use the one same-package candidate. This avoids + # inferring relationships from result ordering or timestamps. + return _legacy_parent_candidates( + cards, + child_job_id, + card.get("work_package"), + ) == {parent_job_id} + + def related_records( records: list[dict[str, Any]], cards: dict[str, dict[str, Any]], @@ -608,6 +872,7 @@ def related_records( role: str, record_types: set[str], profiles: set[str] | None = None, + legacy_warnings: list[str] | None = None, ) -> list[dict[str, Any]]: matches: list[dict[str, Any]] = [] for record in records: @@ -621,12 +886,19 @@ def related_records( continue if profiles is not None and card.get("profile") not in profiles: continue - if ( - job_id == coder_job_id - or card.get("parent_job_id") == coder_job_id - or record.get("parent_job_id") == coder_job_id - ): + if record_matches_parent(record, cards, coder_job_id): matches.append(record) + explicit_card, _ = _parent_job_value(card) + explicit_record, _ = _parent_job_value(record) + if legacy_warnings is not None and not explicit_card and not explicit_record: + package = card.get("work_package") or "task-level" + legacy_warnings.append( + "Hermes Runtime compatibility warning: legacy job " + f"{job_id} has no parent_job_id and was matched to " + f"{coder_job_id} only because it is the sole non-reviewer " + f"candidate in work_package {package}. New dispatches must " + "record parent_job_id explicitly." + ) return matches @@ -713,6 +985,7 @@ def stop_completion_reason( runtime_root: Path, task: str, records: list[dict[str, Any]], + legacy_warnings: list[str] | None = None, ) -> str | None: changed_files = git_changed_files(root) cards = task_cards_by_job(records, root) @@ -758,6 +1031,7 @@ def stop_completion_reason( "reviewer", {"checkpoint", "result"}, {"quality", "evidence", "claim", "safety", "closure", "statistics"}, + legacy_warnings, ) if review_records: return None @@ -809,6 +1083,7 @@ def stop_completion_reason( "runner", {"result"}, {"test", "build", "validation"}, + legacy_warnings, ) has_passing_test = any( runner_result_has_passing_test(runner_result, run_manifests) @@ -827,6 +1102,7 @@ def stop_completion_reason( "reviewer", {"checkpoint", "result"}, {"quality", "safety"}, + legacy_warnings, ) if not review_records: return ( @@ -847,6 +1123,10 @@ def guard_tool_input_permissions( tool_name = str(data.get("tool_name") or "") tool_input = data.get("tool_input", {}) if tool_name == "Bash": + command = bash_command_from_tool_input(tool_input) + if not command: + return "Hermes Runtime: Bash command is missing." + role: str | None = None agent_id = data.get("agent_id") if isinstance(agent_id, str) and agent_id: scripts_dir = root / ".trellis" / "scripts" @@ -862,8 +1142,15 @@ def guard_tool_input_permissions( except Exception: return "Hermes Runtime: Bash caller has no valid dispatch binding." role = str(bound_dispatch.get("role") or "") - if role not in BASH_EXECUTION_ROLES: - return f"Hermes Runtime: {role} dispatch cannot execute Bash." + else: + role = declared_worker_role(data) + if role not in BASH_EXECUTION_ROLES: + return f"Hermes Runtime: {role or 'unbound'} dispatch cannot execute Bash." + if not is_worker_bash_command_allowed(role, command): + return ( + "Hermes Runtime: Bash command is outside the role's bounded command surface. " + "Use runner.py for registered execution or delegate the work." + ) return None else: target_files = collect_tool_target_files(root, tool_name, tool_input) @@ -1351,10 +1638,21 @@ def main() -> int: print(json.dumps(block_payload(event_name, closure_reason), ensure_ascii=False)) return 0 records = read_worker_records(worker_records) - reason = stop_completion_reason(root, runtime_root, task, records) + legacy_warnings: list[str] = [] + reason = stop_completion_reason( + root, + runtime_root, + task, + records, + legacy_warnings, + ) if reason is not None: print(json.dumps(block_payload(event_name, reason), ensure_ascii=False)) return 0 + if legacy_warnings: + warning = " ".join(dict.fromkeys(legacy_warnings)) + print(json.dumps(context_payload(event_name, warning), ensure_ascii=False)) + return 0 if event_name == "PreToolUse": tool_name = str(data.get("tool_name") or "") diff --git a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py index 18602a0..13dfa10 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -252,16 +252,39 @@ def compact(value: object, limit: int) -> str: return "\n".join(lines) -def _handoff_state(task_dir: Path) -> tuple[str, str | None]: - """Return a lightweight marker for an optional handoff summary.""" +def _handoff_state(task_dir: Path, current_revision: int) -> tuple[str, str | None]: + """Classify an optional recovery summary without treating it as authority.""" handoff = task_dir / "HANDOFF.md" if not handoff.is_file(): return "missing", None try: stamp = handoff.stat().st_mtime_ns - except OSError: + content = handoff.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): return "unreadable", None - return f"available:{stamp}", None + match = re.search(r"(?m)^##\s+Task Revision\s*\n\s*(\d+)\s*$", content) + if match is None: + return ( + f"legacy:{stamp}", + "Legacy HANDOFF.md has no Task Revision. It is only a recovery hint; task.json and the Task Capsule remain authoritative.", + ) + handoff_revision = int(match.group(1)) + if handoff_revision != current_revision: + relation = "older than" if handoff_revision < current_revision else "newer than" + return ( + f"stale:{handoff_revision}:{stamp}", + "HANDOFF.md is stale: Task Revision " + f"{handoff_revision} is {relation} current revision {current_revision}. " + "Use task.json and the Task Capsule; do not read the handoff body as current state.", + ) + try: + handoff_ref = task_dir.relative_to(task_dir.parents[2]).as_posix() + except (ValueError, IndexError): + handoff_ref = f".trellis/tasks/{task_dir.name}" + return ( + f"fresh:{handoff_revision}:{stamp}", + f"If this session resumes after a pause or handoff, read {handoff_ref}/HANDOFF.md once if needed. task.json remains the source of truth.", + ) def _context_reset_requested(input_data: dict) -> bool: @@ -325,16 +348,11 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None context_reset = _context_reset_requested(input_data) if context_reset: previous = None - handoff_marker, handoff_problem = _handoff_state(task_dir) + current_revision = revision if isinstance(revision, int) and not isinstance(revision, bool) else 0 + handoff_marker, handoff_problem = _handoff_state(task_dir, current_revision) seen_handoff_marker = previous.get("handoff_marker") if isinstance(previous, dict) else None if handoff_marker == seen_handoff_marker: handoff_notice = None - elif handoff_marker.startswith("available:"): - try: - handoff_ref = task_dir.relative_to(root).as_posix() - except ValueError: - handoff_ref = ".trellis/tasks/" + task_dir.name - handoff_notice = f"If this session resumes after a pause or handoff, read {handoff_ref}/HANDOFF.md once. task.json remains the source of truth." else: handoff_notice = handoff_problem resume_notice = _build_task_resume_notice( 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 d131e75..253c1ce 100644 --- a/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md +++ b/packages/cli/src/templates/trellis/hermes/HERMES_MAIN_AGENT_BOOT_GUARD.md @@ -157,9 +157,10 @@ As Main Agent, you must not directly: * overwrite subagent records to make the state look cleaner; * rely on previous chat history as the source of truth. -Allowed main-agent shell inspection is intentionally narrow: `git status`, -`git diff ...`, `git log ...`, and `cat` / `jq` over RecordBus JSONL under -`.trellis/tasks//hermes/` or `.ai/records/`. +Allowed main-agent Bash is intentionally narrow: deterministic `task.py`, +`dispatch.py`, and listed `closure.py` control commands, plus limited +read-only `git status`, `git diff`, `git log`, and branch inspection. Prefer +`Read` for records; `cat` and `jq` are not part of the coordinator surface. If code must be changed, route the task to a **coder subagent** with explicit allowed files, forbidden files, task scope, and required record output. @@ -204,8 +205,8 @@ subagent_context_policy = validated_dispatch_only ``` The main agent creates a validated dispatch and passes only its `job_id` to -Claude Agent. Codex native dispatch is advisory; enforced Codex work uses the -strict `dispatch.py run` wrapper. The canonical body contains role/profile, +Claude Agent. Codex native dispatch is advisory; hooks and deterministic +records provide the supported gates. The canonical body contains role/profile, revision, package, objective, and at most three refs within 2000 characters. The subagent reads a referenced file only when the assignment needs it. Full @@ -289,9 +290,10 @@ safety, closure, or statistics. Evidence judgment must not patch code, set `PreToolUse` is the role firewall. For Claude Agent it accepts only a validated `job_id`, rejects async dispatch, and replaces the original prompt with the -canonical body. `SubagentStop` validates the Result Envelope; `PostToolUse` -returns only its sanitized summary. Explicit writers still pass through -`task_card` and file-boundary checks. +canonical body. `Edit` and `Write` are checked against `allowed_files` before +they run. Bash has only role and bounded-command authorization; `SubagentStop` +and `Stop` validate records and closure conditions afterward. These checks are +not an OS sandbox, malicious-process isolation, or arbitrary-shell defense. `Stop` is read-only. It reads RecordBus, git diff, and `run_manifest.jsonl`. It must not write records or run tests. diff --git a/packages/cli/src/templates/trellis/hermes/roles/base.md b/packages/cli/src/templates/trellis/hermes/roles/base.md index ad6f6ae..95689a4 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/base.md +++ b/packages/cli/src/templates/trellis/hermes/roles/base.md @@ -3,6 +3,8 @@ - Read the Task Capsule before any larger task artifact. - Treat `task.json` and append-only Hermes records as state; chat statements do not change state. - Stay inside task-card file and command permissions. +- `Edit` and `Write` actions are checked against `allowed_files` before they run; this is a file-boundary check, not process isolation. +- `Bash` uses a small role and command allowlist plus later record and closure checks. It is not a sandbox or protection against a malicious process. - Load only the current assignment and at most three direct references by default. - Cite concrete records, artifacts, commands, or sources in the required output. - Stop and report a blocker for high-risk research changes or missing human approval. diff --git a/packages/cli/src/templates/trellis/hermes/roles/coder.md b/packages/cli/src/templates/trellis/hermes/roles/coder.md index 06329af..0b29c1a 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/coder.md +++ b/packages/cli/src/templates/trellis/hermes/roles/coder.md @@ -6,8 +6,9 @@ Modify code, tests, or configuration inside an explicit task-card boundary. ## Allowed actions -- Edit `allowed_files` while avoiding every `forbidden_files` path. -- Add focused tests and run targeted implementation checks. +- Edit only files matching `allowed_files` while avoiding every `forbidden_files` path. +- Add focused tests; route test, build, and experiment execution to a runner. +- Use Bash only for the bounded read-only Git inspection allowed to a coder. - Append checkpoints, implementation results, risks, and repair notes. ## Forbidden actions diff --git a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md index 0b7eea4..5f4623e 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/reviewer.md +++ b/packages/cli/src/templates/trellis/hermes/roles/reviewer.md @@ -9,6 +9,7 @@ Independently review quality, evidence, claims, safety, statistics, or closure a - Read the current diff, task card, relevant records, artifacts, and at most the references needed for the review. - Append review findings, rejection records, and compact gap lists. - Recommend approval prerequisites while preserving `HumanGate`. +- For quality, evidence, claim, or statistics review, bind `parent_job_id` to the exact job being reviewed. Closure and independent safety review may remain task-level. ## Forbidden actions diff --git a/packages/cli/src/templates/trellis/hermes/roles/runner.md b/packages/cli/src/templates/trellis/hermes/roles/runner.md index 7615dc5..a0384c2 100644 --- a/packages/cli/src/templates/trellis/hermes/roles/runner.md +++ b/packages/cli/src/templates/trellis/hermes/roles/runner.md @@ -6,7 +6,7 @@ Execute tests, experiments, builds, and validation with reproducible run records ## Allowed actions -- Run commands authorized by the task card. +- Run registered commands through `runner.py`; `allowed_commands` is a reproducibility boundary, not a security sandbox. - Register run manifests, artifact paths, checkpoints, results, and risks. - Report environment failures and request a coder when implementation changes are needed. diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index d9feeee..bcf480e 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -1840,6 +1840,8 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: + revision = data.get("hermes_revision", 0) + revision_text = str(revision) if isinstance(revision, int) and not isinstance(revision, bool) else "unknown" packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] completed = [str(item.get("id")) for item in packages if item.get("status") == "done"] evidence = _dedupe([ @@ -1853,6 +1855,9 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None [ "# Task Handoff", "", + "## Task Revision", + revision_text, + "", "## Current Goal", str(data.get("intent") or data.get("title") or "-"), "", diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index 401c5f5..3654e68 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -38,6 +38,7 @@ MAX_INVALID_RESULTS = 2 EXECUTION_ROLES = {"coder", "runner"} TASK_LEVEL_ROLES = {"planner", "researcher", "reviewer"} +INDEPENDENT_REVIEWER_PROFILES = {"closure", "safety"} PROJECT_CONTEXT_FILES = { "background": ".trellis/project/BACKGROUND.md", "research_plan": ".trellis/project/RESEARCH_PLAN.md", @@ -122,6 +123,7 @@ "task_revision", "role", "profile", + "parent_job_id", "status", "conclusion", "uncertainties", @@ -210,6 +212,7 @@ def create_dispatch( role = normalized.role profile = normalized.profile work_package = _normalize_optional_text(spec.get("work_package")) + parent_job_id = _normalize_optional_text(spec.get("parent_job_id")) constraints = closure_constraints(task) allowed_files = _normalize_patterns(spec.get("allowed_files"), "allowed_files") forbidden_files = _normalize_patterns(spec.get("forbidden_files"), "forbidden_files") @@ -245,12 +248,23 @@ def create_dispatch( role, profile, work_package, + parent_job_id, objective, ) _validate_job_id(job_id) path = dispatch_path(task_dir, job_id) if path.exists(): raise DispatchError("duplicate_job", f"dispatch already exists for {job_id}") + _validate_parent_job( + task_dir, + task, + repo_root, + job_id=job_id, + role=role, + profile=profile, + work_package=work_package, + parent_job_id=parent_job_id, + ) mode = str(task.get("closure_mode") or "lean") gate_errors, gate_warnings = closure_mode_gate( @@ -283,6 +297,7 @@ def create_dispatch( "role": role, "profile": profile, "work_package": work_package, + "parent_job_id": parent_job_id, "handoff_writer": handoff_writer, "objective": objective, "allowed_refs": refs, @@ -444,6 +459,22 @@ def validate_dispatch( ) if dispatch.get("task_revision") != dispatch.get("hermes_revision"): errors.append("task_revision") + try: + parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) + if dispatch.get("parent_job_id") != parent_job_id: + errors.append("parent_job_id") + _validate_parent_job( + task_dir, + task, + repo_root, + job_id=str(dispatch.get("job_id") or ""), + role=str(dispatch.get("role") or ""), + profile=str(dispatch.get("profile") or ""), + work_package=_normalize_optional_text(dispatch.get("work_package")), + parent_job_id=parent_job_id, + ) + except DispatchError as exc: + errors.append(exc.code) if dispatch.get("allowed_refs") != dispatch.get("refs"): errors.append("allowed_refs") if dispatch.get("forbidden") != dispatch.get("forbidden_files"): @@ -594,6 +625,7 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: f"task: {dispatch.get('task_path')} @ revision {dispatch.get('hermes_revision')}", f"role: {dispatch.get('role')}:{dispatch.get('profile')}", f"work_package: {dispatch.get('work_package') or 'null'}", + f"parent_job_id: {dispatch.get('parent_job_id') or 'null'}", f"objective: {dispatch.get('objective')}", "refs: " + (", ".join(refs) if refs else "none"), "allowed_files: " + (", ".join(_string_list(dispatch.get("allowed_files"))) or "none"), @@ -604,7 +636,7 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: "Read only these refs unless the objective names a deterministic ledger check.", "For targeted verification, run only the current work package's directed check and record basic evidence; do not claim completion from chat.", "Do not include logs, diffs, search history, tool traces, secrets, or absolute user paths.", - "Return exactly one JSON object with: schema, job_id, task_revision, role, profile, status(success|failure|blocked), conclusion, evidence_refs, artifact_refs, changed_files, verification, risks, uncertainties, next_action.", + "Return exactly one JSON object with: schema, job_id, task_revision, role, profile, parent_job_id when present, status(success|failure|blocked), conclusion, evidence_refs, artifact_refs, changed_files, verification, risks, uncertainties, next_action.", f"conclusion must be <= {MAX_CONCLUSION_CHARS} characters; uncertainties is required.", ] if dispatch.get("blind_review"): @@ -761,6 +793,10 @@ def validate_result_envelope( raise DispatchError("result_revision_mismatch", "result task_revision does not match dispatch") if value.get("role") != dispatch.get("role") or value.get("profile") != dispatch.get("profile"): raise DispatchError("result_role_mismatch", "result role/profile does not match dispatch") + result_parent_job_id = _normalize_optional_text(value.get("parent_job_id")) + dispatch_parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) + if "parent_job_id" in value and result_parent_job_id != dispatch_parent_job_id: + raise DispatchError("result_parent_mismatch", "result parent_job_id does not match dispatch") if value.get("status") not in {"success", "failure", "blocked"}: raise DispatchError("invalid_result_status", "status must be success, failure, or blocked") conclusion = _required_text(value.get("conclusion"), "conclusion") @@ -822,6 +858,7 @@ def validate_result_envelope( "task_revision": value["task_revision"], "role": value["role"], "profile": value["profile"], + "parent_job_id": dispatch_parent_job_id, "status": value["status"], "conclusion": conclusion, "uncertainties": uncertainties, @@ -858,6 +895,7 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: "task_revision", "role", "profile", + "parent_job_id", "status", "conclusion", "uncertainties", @@ -880,6 +918,7 @@ def sanitized_summary(task_dir: Path, job_id: str) -> dict[str, Any]: "task_revision": dispatch.get("task_revision", dispatch.get("hermes_revision")), "role": dispatch.get("role"), "profile": dispatch.get("profile"), + "parent_job_id": dispatch.get("parent_job_id"), "status": "blocked" if status == "blocked" else status, "conclusion": "Result envelope was rejected by the context firewall.", "uncertainties": [f"invalid_result_attempts={attempts}"], @@ -907,6 +946,7 @@ def list_dispatches(task_dir: Path) -> list[dict[str, Any]]: "role": value.get("role"), "profile": value.get("profile"), "work_package": value.get("work_package"), + "parent_job_id": value.get("parent_job_id"), "hermes_revision": value.get("hermes_revision"), "status": value.get("status"), "invalid_result_attempts": value.get("invalid_result_attempts", 0), @@ -1012,6 +1052,7 @@ def result_json_schema() -> dict[str, Any]: "task_revision": {"type": "integer", "minimum": 0}, "role": {"type": "string", "enum": ["planner", "researcher", "coder", "runner", "reviewer"]}, "profile": {"type": "string"}, + "parent_job_id": {"type": ["string", "null"], "pattern": JOB_ID_RE.pattern}, "status": {"type": "string", "enum": ["success", "failure", "blocked"]}, "conclusion": {"type": "string", "maxLength": MAX_CONCLUSION_CHARS}, "uncertainties": string_array, @@ -1046,6 +1087,7 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: "revision": "pass", "role_profile": "pass", "work_package": "pass", + "parent_job": "pass", "refs": "pass", "sensitive_content": "pass", "absolute_user_path": "pass", @@ -1057,6 +1099,7 @@ def _dispatch_audit(dispatch: dict[str, Any]) -> dict[str, Any]: "ref_count": len(dispatch.get("refs") or []), "allowed_file_patterns": len(dispatch.get("allowed_files") or []), "forbidden_file_patterns": len(dispatch.get("forbidden_files") or []), + "has_parent_job": dispatch.get("parent_job_id") is not None, }, "dispatch_sha256": _stable_hash(dispatch, {"audit"}), } @@ -1073,6 +1116,7 @@ def _sanitized_result( sanitized["dispatch_revision"] = dispatch.get("hermes_revision") sanitized["role"] = dispatch.get("role") sanitized["profile"] = dispatch.get("profile") + sanitized["parent_job_id"] = dispatch.get("parent_job_id") sanitized["work_package"] = dispatch.get("work_package") verification = dict(result.get("verification") or {}) verification["run_refs"] = list(result.get("run_refs") or []) @@ -1235,6 +1279,7 @@ def _append_worker_task_card(task_dir: Path, dispatch: dict[str, Any]) -> None: "profile": dispatch["profile"], "objective": dispatch["objective"], "work_package": dispatch["work_package"], + "parent_job_id": dispatch["parent_job_id"], "hermes_revision": dispatch["hermes_revision"], "worktree_id": dispatch["worktree_id"], "status": "queued", @@ -1262,6 +1307,7 @@ def _append_worker_result( "id": f"cp-{dispatch['job_id']}", "timestamp": now_utc(), "job_id": dispatch["job_id"], + "parent_job_id": dispatch.get("parent_job_id"), "checkpoint": "result-envelope-validated", "resume_from": "sanitized result", "evidence_refs": result["evidence_refs"], @@ -1273,6 +1319,7 @@ def _append_worker_result( "id": f"rs-{dispatch['job_id']}", "timestamp": now_utc(), "job_id": dispatch["job_id"], + "parent_job_id": dispatch.get("parent_job_id"), "status": "done" if result["status"] == "success" else result["status"], "summary": result["conclusion"], "uncertainties": result["uncertainties"], @@ -1422,6 +1469,48 @@ def _validate_handoff_file_boundary( ) +def _validate_parent_job( + task_dir: Path, + task: dict[str, Any], + repo_root: Path, + *, + job_id: str, + role: str, + profile: str, + work_package: str | None, + parent_job_id: str | None, +) -> None: + """Bind formal review work to one existing job in the same package.""" + requires_parent = role == "reviewer" and profile not in INDEPENDENT_REVIEWER_PROFILES + if parent_job_id is None: + if requires_parent: + raise DispatchError( + "missing_parent_job", + "reviewer dispatch requires parent_job_id unless it is closure or safety review", + ) + return + _validate_job_id(parent_job_id) + if parent_job_id == job_id: + raise DispatchError("parent_job_self_reference", "parent_job_id cannot equal job_id") + parent = load_dispatch(task_dir, parent_job_id) + if parent.get("job_id") != parent_job_id: + raise DispatchError("parent_job_invalid", "parent dispatch job_id does not match its record") + expected_task_id = str(task.get("id") or task_dir.name) + if parent.get("task_id") != expected_task_id: + raise DispatchError("parent_job_task_mismatch", "parent dispatch belongs to a different task") + if parent.get("task_path") != _repo_relative(task_dir, repo_root): + raise DispatchError("parent_job_task_mismatch", "parent dispatch path does not match this task") + try: + parent_work_package = _normalize_optional_text(parent.get("work_package")) + except DispatchError as exc: + raise DispatchError("parent_job_invalid", "parent dispatch has an invalid work_package") from exc + if parent_work_package != work_package: + raise DispatchError( + "parent_job_work_package_mismatch", + "parent dispatch must use the same work_package", + ) + + def _validate_work_package( task: dict[str, Any], role: str, @@ -1727,10 +1816,11 @@ def _generated_job_id( role: str, profile: str, work_package: str | None, + parent_job_id: str | None, objective: str, ) -> str: payload = json.dumps( - [task_name, revision, role, profile, work_package, objective], + [task_name, revision, role, profile, work_package, parent_job_id, objective], ensure_ascii=False, separators=(",", ":"), ) diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index addc9e9..68139f4 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -42,6 +42,7 @@ def build_parser() -> argparse.ArgumentParser: create.add_argument("--role", required=True) create.add_argument("--profile") create.add_argument("--work-package") + create.add_argument("--parent-job-id") create.add_argument("--objective", required=True) create.add_argument("--ref", action="append", default=[]) create.add_argument("--allowed-file", action="append", default=[]) @@ -133,6 +134,7 @@ def main(argv: list[str] | None = None) -> int: "role": args.role, "profile": args.profile, "work_package": args.work_package, + "parent_job_id": args.parent_job_id, "objective": args.objective, "refs": args.ref, "allowed_files": args.allowed_file, diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runner.py b/packages/cli/src/templates/trellis/scripts/hermes/runner.py index 113cb44..c9b1c21 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runner.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runner.py @@ -88,8 +88,13 @@ def heartbeat_record( } -def checkpoint_record(args: argparse.Namespace) -> dict[str, Any]: - return { +def checkpoint_record( + args: argparse.Namespace, + parent_job_id: Any = None, + *, + has_parent_field: bool = False, +) -> dict[str, Any]: + record: dict[str, Any] = { "type": "checkpoint", "id": make_record_id("cp", args.job_id), "timestamp": now_utc(), @@ -99,10 +104,20 @@ def checkpoint_record(args: argparse.Namespace) -> dict[str, Any]: "evidence_refs": [], "open_items": [], } + if has_parent_field: + record["parent_job_id"] = parent_job_id + return record -def result_record(args: argparse.Namespace, manifest_id: str, outputs: list[str]) -> dict[str, Any]: - return { +def result_record( + args: argparse.Namespace, + manifest_id: str, + outputs: list[str], + parent_job_id: Any = None, + *, + has_parent_field: bool = False, +) -> dict[str, Any]: + record: dict[str, Any] = { "type": "result", "id": make_record_id("rs", args.job_id), "timestamp": now_utc(), @@ -114,6 +129,9 @@ def result_record(args: argparse.Namespace, manifest_id: str, outputs: list[str] "risk_flags": [], "handoff": f"run manifest {manifest_id}", } + if has_parent_field: + record["parent_job_id"] = parent_job_id + return record def rejection_record( @@ -315,6 +333,9 @@ def run_command(args: argparse.Namespace) -> int: file=sys.stderr, ) return 1 + # Snapshot declared input hashes before process launch. The command may + # legitimately transform an input later, but the manifest must preserve + # the exact pre-run material it received. input_entries = [path_entry(root, cwd, value, "input") for value in inputs] hermes_dir = worker_path.parent runs_dir = hermes_dir / "runs" @@ -324,7 +345,16 @@ def run_command(args: argparse.Namespace) -> int: stdout_path = run_dir / "stdout.log" stderr_path = run_dir / "stderr.log" started_at = now_utc() - append_record(worker_path, checkpoint_record(args)) + has_parent_field = "parent_job_id" in task_card + parent_job_id = task_card.get("parent_job_id") + append_record( + worker_path, + checkpoint_record( + args, + parent_job_id, + has_parent_field=has_parent_field, + ), + ) append_record(worker_path, heartbeat_record(args.job_id, args.checkpoint, args.summary, interval_seconds)) last_heartbeat = time.monotonic() launch_error: OSError | None = None @@ -380,7 +410,16 @@ def run_command(args: argparse.Namespace) -> int: append_record(manifest_path, manifest_record) if exit_code == 0: - append_record(worker_path, result_record(args, run_id, [entry["path"] for entry in output_entries])) + append_record( + worker_path, + result_record( + args, + run_id, + [entry["path"] for entry in output_entries], + parent_job_id, + has_parent_field=has_parent_field, + ), + ) else: append_record( worker_path, diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index e9e4d1a..02ff74f 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -54,6 +54,7 @@ ] CODER_REVIEW_HANDOFFS = {"review", "claim_ready"} TERMINAL_WORKER_RECORD_TYPES = {"result", "rejection", "stalled"} +INDEPENDENT_REVIEWER_PROFILES = {"closure", "safety"} APPROVAL_CHANGE_ID_FIELDS = { "change_id", "change_ids", @@ -1171,6 +1172,8 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: if len(entries) > 1: lines = ", ".join(str(entry.line_number) for entry in entries) errors.append(f"duplicate task_card for job_id {job_id}: lines {lines}") + continue + errors.extend(_parent_job_card_errors(entries[0], task_cards)) active_writers: dict[str, list[JsonlRecord]] = {} for job_id, entries in task_cards.items(): @@ -1201,6 +1204,8 @@ def validate_worker_records(records: list[JsonlRecord]) -> list[str]: if record_type == "result" and isinstance(job_id, str): entries = task_cards.get(job_id, []) card = entries[0].value if len(entries) == 1 else None + if card is not None: + errors.extend(_parent_job_result_errors(entry, card)) if card and not is_change_set_allowed(card, value.get("changed_files", [])): errors.append( f"line {entry.line_number}: result changed files violate task_card permissions" @@ -1259,6 +1264,116 @@ def is_coder_review_handoff(record: dict[str, Any]) -> bool: ) +def _parent_job_value(value: dict[str, Any]) -> tuple[bool, str | None]: + """Return whether a record uses the new parent field and its valid value.""" + if "parent_job_id" not in value: + return False, None + parent = value.get("parent_job_id") + if parent is None: + return True, None + if isinstance(parent, str) and parent.strip(): + return True, parent.strip() + return True, "" + + +def _parent_job_card_errors( + entry: JsonlRecord, + task_cards: dict[str, list[JsonlRecord]], +) -> list[str]: + card = entry.value + job_id = card.get("job_id") + if not isinstance(job_id, str) or not job_id: + return [] + explicit, parent_job_id = _parent_job_value(card) + role = card.get("role") + profile = card.get("profile") + if not explicit: + return [] + if parent_job_id == "": + return [f"line {entry.line_number}: parent_job_id must be a non-empty string or null"] + if ( + role == "reviewer" + and profile not in INDEPENDENT_REVIEWER_PROFILES + and parent_job_id is None + ): + return [ + f"line {entry.line_number}: reviewer task_card requires parent_job_id unless profile is closure or safety" + ] + if parent_job_id is None: + return [] + if parent_job_id == job_id: + return [f"line {entry.line_number}: parent_job_id cannot equal job_id"] + parent_entries = task_cards.get(parent_job_id, []) + if len(parent_entries) != 1: + return [f"line {entry.line_number}: parent_job_id {parent_job_id} has no unique task_card"] + parent = parent_entries[0].value + if parent.get("work_package") != card.get("work_package"): + return [ + f"line {entry.line_number}: parent_job_id {parent_job_id} must use the same work_package" + ] + return [] + + +def _parent_job_result_errors(entry: JsonlRecord, card: dict[str, Any]) -> list[str]: + explicit_card, card_parent = _parent_job_value(card) + explicit_result, result_parent = _parent_job_value(entry.value) + if not explicit_card or not explicit_result: + return [] + if result_parent == "": + return [f"line {entry.line_number}: result parent_job_id must be a non-empty string or null"] + if result_parent != card_parent: + return [f"line {entry.line_number}: result parent_job_id does not match task_card"] + return [] + + +def _legacy_parent_candidates( + task_cards: dict[str, list[JsonlRecord]], + child_job_id: str, + work_package: Any, +) -> set[str]: + candidates: set[str] = set() + for candidate_job_id, entries in task_cards.items(): + if candidate_job_id == child_job_id or len(entries) != 1: + continue + candidate = entries[0].value + if candidate.get("role") == "reviewer": + continue + if candidate.get("work_package") == work_package: + candidates.add(candidate_job_id) + return candidates + + +def _record_matches_parent( + record: dict[str, Any], + card: dict[str, Any], + task_cards: dict[str, list[JsonlRecord]], + parent_job_id: str, +) -> bool: + explicit_card, card_parent = _parent_job_value(card) + explicit_record, record_parent = _parent_job_value(record) + parent_entries = task_cards.get(parent_job_id, []) + if len(parent_entries) != 1: + return False + same_package = parent_entries[0].value.get("work_package") == card.get("work_package") + if explicit_card: + return same_package and card_parent == parent_job_id and ( + not explicit_record or record_parent == card_parent + ) + if explicit_record: + return same_package and record_parent == parent_job_id + child_job_id = card.get("job_id") + if not isinstance(child_job_id, str): + return False + # Old records did not carry parent_job_id. Keep compatibility only when + # same-package ownership has exactly one possible parent; never infer by + # timestamp or result ordering. + return _legacy_parent_candidates( + task_cards, + child_job_id, + card.get("work_package"), + ) == {parent_job_id} + + def has_related_record( records: list[JsonlRecord], task_cards: dict[str, list[JsonlRecord]], @@ -1283,11 +1398,7 @@ def has_related_record( continue if profiles is not None and card.get("profile") not in profiles: continue - if ( - result_job_id == coder_job_id - or card.get("parent_job_id") == coder_job_id - or value.get("parent_job_id") == coder_job_id - ): + if _record_matches_parent(value, card, task_cards, coder_job_id): return True return False @@ -1313,6 +1424,15 @@ def compatibility_warnings(path: Path, kind: str) -> list[str]: seen.add(warning) message = f"line {entry.line_number}: {warning}" warnings.append(message) + role = entry.value.get("role") + if role in {"runner", "reviewer"} and "parent_job_id" not in entry.value: + message = ( + f"line {entry.line_number}: legacy task_card has no parent_job_id; " + "relationship matching is limited to one unambiguous same-work_package candidate" + ) + if message not in seen: + seen.add(message) + warnings.append(message) return warnings @@ -1819,20 +1939,40 @@ def _repo_file(root: Path, value: Any, label: str) -> tuple[Path | None, str | N return candidate, None -def _task_requires_data_preflight(path: Path) -> bool: +def _task_requires_data_preflight(path: Path) -> tuple[bool, list[str]]: task_path = path.parent.parent / "task.json" + if not task_path.is_file(): + return False, [ + f"{path}: task.json is missing; formal experiment validation requires task state" + ] + if has_symlink_component(task_path): + return False, [ + f"{path}: task.json cannot be read through a symlink for formal experiment validation" + ] try: - task = json.loads(task_path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError): - return False - if not isinstance(task, dict) or task.get("research_route") != "exploration": - return False + content = task_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + return False, [f"{path}: task.json cannot be read: {exc}"] + try: + task = json.loads(content) + except json.JSONDecodeError as exc: + return False, [f"{path}: task.json contains invalid JSON: {exc.msg}"] + if not isinstance(task, dict): + return False, [f"{path}: task.json must be a mapping"] + # Legacy non-closure tasks keep their existing experiment behavior. Only + # closure tasks with an explicit data-changing exploration route require + # data_preflight. + if not any(key in task for key in ("closure_state", "hermes_phase", "work_packages")): + return False, [] + if task.get("research_route") != "exploration": + return False, [] fields = task.get("research_change_fields") - return isinstance(fields, list) and bool( - DATA_PREFLIGHT_CHANGE_FIELDS.intersection( - item for item in fields if isinstance(item, str) - ) - ) + normalized_fields = { + item.strip().casefold() + for item in fields + if isinstance(item, str) and item.strip() + } if isinstance(fields, list) else set() + return bool(DATA_PREFLIGHT_CHANGE_FIELDS.intersection(normalized_fields)), [] def _load_preflight_checks(path: Path) -> tuple[dict[str, Any], str | None]: @@ -1853,7 +1993,9 @@ def validate_data_preflight( path: Path, experiment: dict[str, Any], ) -> tuple[list[Path], list[str]]: - required = _task_requires_data_preflight(path) + required, task_errors = _task_requires_data_preflight(path) + if task_errors: + return [], task_errors preflight = experiment.get("data_preflight") if preflight is None: if required: diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index 1d8c386..c84568b 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -302,24 +302,6 @@ def cmd_finish(args: argparse.Namespace) -> int: task_json_path = repo_root / current / FILE_TASK_JSON - if task_json_path.is_file(): - task_data = read_json(task_json_path) - if isinstance(task_data, dict): - from common.closure import ClosureError, is_closure_task, write_handoff - - if is_closure_task(task_data) and task_data.get("closure_state") != "closed": - try: - handoff_path = write_handoff(task_json_path.parent, task_data, repo_root) - print(colored( - f"✓ Handoff updated: {handoff_path.relative_to(repo_root)}", - Colors.GREEN, - )) - except (ClosureError, OSError): - print(colored( - "Warning: could not update HANDOFF.md; task state remains in task.json.", - Colors.YELLOW, - )) - cleared = clear_active_task(repo_root) print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 2582655..b532462 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -288,8 +288,8 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Copilot/Gemini/Qoder and `trellis-research`: every dispatch prompt starts with `Active task: ` before role-specific instructions. [workflow-state:in_progress] -Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. -Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, or blocking. At closure, reviewer checks closure and runner performs final validation/archive actions. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Quality, evidence, claim, and statistics reviews bind `parent_job_id` to the exact job under review; closure and independent safety reviews may remain task-level. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Runner commands go through `runner.py` so the run manifest records the actual execution. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. +Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. Main-session default: dispatch the canonical role required by the current package. Only code packages use implement/check sub-agents; experiments use runner plus evidence/statistics review, and literature or planning packages use researcher/planner plus an appropriate independent review. Sub-agent self-exemption: an active sub-agent never dispatches another worker. Dispatch is main session only. diff --git a/packages/cli/src/utils/codex-config.ts b/packages/cli/src/utils/codex-config.ts new file mode 100644 index 0000000..1a48ce2 --- /dev/null +++ b/packages/cli/src/utils/codex-config.ts @@ -0,0 +1,382 @@ +export const CODEX_CONFIG_BLOCK_START = "# TRELLIS:CODEX_CONFIG:START"; +export const CODEX_CONFIG_BLOCK_END = "# TRELLIS:CODEX_CONFIG:END"; +export const CODEX_MODEL_BLOCK_START = "# TRELLIS:CODEX_MODEL_DEFAULTS:START"; +export const CODEX_MODEL_BLOCK_END = "# TRELLIS:CODEX_MODEL_DEFAULTS:END"; + +const LEGACY_MODEL_LINE = /^\s*model\s*=\s*"gpt-5\.6-sol"\s*$/; +const LEGACY_REASONING_LINE = /^\s*model_reasoning_effort\s*=\s*"high"\s*$/; +const MODEL_ASSIGNMENT = /^\s*model\s*=/; +const REASONING_ASSIGNMENT = /^\s*model_reasoning_effort\s*=/; +const TABLE_HEADER = /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/; + +export interface CodexConfigMergeResult { + content: string; + changed: boolean; + migratedLegacyDefaults: boolean; +} + +interface BlockRange { + start: number; + end: number; +} + +interface TopLevelModelAssignment { + index: number; + line: string; + kind: "model" | "reasoning"; +} + +function markerRange( + content: string, + startMarker: string, + endMarker: string, +): BlockRange | null { + const start = content.indexOf(startMarker); + if ( + start < 0 || + content.indexOf(startMarker, start + startMarker.length) !== -1 + ) { + return null; + } + const endStart = content.indexOf(endMarker, start + startMarker.length); + if ( + endStart < 0 || + content.indexOf(endMarker, endStart + endMarker.length) !== -1 + ) { + return null; + } + return { start, end: endStart + endMarker.length }; +} + +function hasAnyTrellisMarker(content: string): boolean { + return [ + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ].some((marker) => content.includes(marker)); +} + +function replaceRange( + content: string, + range: BlockRange, + replacement: string, +): string { + return content.slice(0, range.start) + replacement + content.slice(range.end); +} + +function topLevelModelAssignments(content: string): TopLevelModelAssignment[] { + const assignments: TopLevelModelAssignment[] = []; + let inTable = false; + + for (const [index, line] of content.split(/\r?\n/).entries()) { + if (TABLE_HEADER.test(line)) { + inTable = true; + continue; + } + if (inTable) continue; + if (MODEL_ASSIGNMENT.test(line)) { + assignments.push({ index, line, kind: "model" }); + } else if (REASONING_ASSIGNMENT.test(line)) { + assignments.push({ index, line, kind: "reasoning" }); + } + } + + return assignments; +} + +function topLevelLegacyDefaultLines(content: string): { + model: number; + reasoning: number; +} | null { + const assignments = topLevelModelAssignments(content); + if (assignments.length !== 2) return null; + + const model = assignments.find( + (assignment) => + assignment.kind === "model" && LEGACY_MODEL_LINE.test(assignment.line), + ); + const reasoning = assignments.find( + (assignment) => + assignment.kind === "reasoning" && + LEGACY_REASONING_LINE.test(assignment.line), + ); + + return model && reasoning + ? { model: model.index, reasoning: reasoning.index } + : null; +} + +function customTopLevelModelLines(content: string): string[] | null { + const assignments = topLevelModelAssignments(content); + if (assignments.length === 0 || topLevelLegacyDefaultLines(content)) { + return null; + } + return assignments.map((assignment) => assignment.line); +} + +function modelBlock(template: string): string | null { + const range = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range ? template.slice(range.start, range.end) : null; +} + +function buildModelBlock(lines: string[]): string { + return [CODEX_MODEL_BLOCK_START, ...lines, CODEX_MODEL_BLOCK_END].join("\n"); +} + +function preserveCustomModelBlock(template: string, source: string): string { + const customLines = customTopLevelModelLines(source); + if (!customLines) return template; + + const range = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range + ? replaceRange(template, range, buildModelBlock(customLines)) + : template; +} + +function removeModelBlock(content: string): string { + const range = markerRange( + content, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + return range ? replaceRange(content, range, "") : content; +} + +function migrateLegacyDefaults( + existing: string, + template: string, +): string | null { + const defaults = topLevelLegacyDefaultLines(existing); + const replacement = modelBlock(template); + if (!defaults || !replacement) return null; + + const lines = existing.split(/\r?\n/); + const insertAt = Math.min(defaults.model, defaults.reasoning); + const remove = new Set([defaults.model, defaults.reasoning]); + const result: string[] = []; + for (const [index, line] of lines.entries()) { + if (index === insertAt) { + result.push(replacement); + } + if (!remove.has(index)) { + result.push(line); + } + } + return result.join("\n"); +} + +function mergeWholeConfigBlock( + existing: string, + template: string, +): string | null { + const current = markerRange( + existing, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + if (!current || !replacement) return null; + + const existingBlock = existing.slice(current.start, current.end); + const existingPrefix = existing.slice(0, current.start); + const hasUserModelBeforeBlock = + topLevelModelAssignments(existingPrefix).length > 0; + let nextBlock = template.slice(replacement.start, replacement.end); + + if (hasUserModelBeforeBlock) { + nextBlock = removeModelBlock(nextBlock); + } else { + nextBlock = preserveCustomModelBlock(nextBlock, existingBlock); + } + + return replaceRange(existing, current, nextBlock); +} + +function mergeModelDefaultsBlock( + existing: string, + template: string, +): string | null { + const current = markerRange( + existing, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + if (!current || !replacement) return null; + + const existingPrefix = existing.slice(0, current.start); + const hasUserModelBeforeBlock = + topLevelModelAssignments(existingPrefix).length > 0; + const nextBlock = hasUserModelBeforeBlock + ? "" + : preserveCustomModelBlock( + template.slice(replacement.start, replacement.end), + existing.slice(current.start, current.end), + ); + + return replaceRange(existing, current, nextBlock); +} + +/** + * Preserve content outside Trellis markers. A user-selected model inside a + * managed block survives template updates. An old unmarked exact default pair + * is migrated in place; other unmarked configuration is left untouched. + */ +export function mergeTrellisCodexConfig( + existing: string, + template: string, +): CodexConfigMergeResult { + const wholeBlock = mergeWholeConfigBlock(existing, template); + if (wholeBlock !== null) { + return { + content: wholeBlock, + changed: wholeBlock !== existing, + migratedLegacyDefaults: false, + }; + } + + const defaultsBlock = mergeModelDefaultsBlock(existing, template); + if (defaultsBlock !== null) { + return { + content: defaultsBlock, + changed: defaultsBlock !== existing, + migratedLegacyDefaults: false, + }; + } + + if (hasAnyTrellisMarker(existing)) { + return { content: existing, changed: false, migratedLegacyDefaults: false }; + } + + const migrated = migrateLegacyDefaults(existing, template); + if (migrated !== null) { + return { + content: migrated, + changed: migrated !== existing, + migratedLegacyDefaults: true, + }; + } + + return { content: existing, changed: false, migratedLegacyDefaults: false }; +} + +export function isLegacyCodexDefaultMigration( + existing: string, + candidate: string, +): boolean { + const migrated = migrateLegacyDefaults(existing, candidate); + return migrated !== null && migrated === candidate; +} + +/** True when an update candidate only replaces Trellis-managed configuration. */ +export function isTrellisCodexConfigMerge( + existing: string, + candidate: string, +): boolean { + const blockKinds = [ + [CODEX_CONFIG_BLOCK_START, CODEX_CONFIG_BLOCK_END], + [CODEX_MODEL_BLOCK_START, CODEX_MODEL_BLOCK_END], + ] as const; + + for (const [startMarker, endMarker] of blockKinds) { + const current = markerRange(existing, startMarker, endMarker); + const replacement = markerRange(candidate, startMarker, endMarker); + if (!current || !replacement) continue; + if ( + existing.slice(0, current.start) === + candidate.slice(0, replacement.start) && + existing.slice(current.end) === candidate.slice(replacement.end) + ) { + return true; + } + } + + return isLegacyCodexDefaultMigration(existing, candidate); +} + +function removeLegacyDefaultLines(content: string): string { + const defaults = topLevelLegacyDefaultLines(content); + if (!defaults) return content; + const remove = new Set([defaults.model, defaults.reasoning]); + return content + .split(/\r?\n/) + .filter((_line, index) => !remove.has(index)) + .join("\n"); +} + +function customModelLinesFromBlock(content: string): string { + return customTopLevelModelLines(content)?.join("\n") ?? ""; +} + +/** + * Remove Trellis configuration while preserving a user-selected model. Older + * unmarked files lose only the exact legacy default model pair. + */ +export function stripTrellisCodexConfig(content: string): string { + const wholeBlock = markerRange( + content, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + let result = content; + + if (wholeBlock) { + result = replaceRange( + result, + wholeBlock, + customModelLinesFromBlock( + content.slice(wholeBlock.start, wholeBlock.end), + ), + ); + } else { + const defaultsBlock = markerRange( + result, + CODEX_MODEL_BLOCK_START, + CODEX_MODEL_BLOCK_END, + ); + if (defaultsBlock) { + result = replaceRange( + result, + defaultsBlock, + customModelLinesFromBlock( + result.slice(defaultsBlock.start, defaultsBlock.end), + ), + ); + } + } + + result = removeLegacyDefaultLines(result); + const lines = result.split(/\r?\n/); + const compacted: string[] = []; + let previousBlank = true; + for (const line of lines) { + const blank = line.trim().length === 0; + if (blank && previousBlank) continue; + compacted.push(line); + previousBlank = blank; + } + while ( + compacted.length > 0 && + compacted[compacted.length - 1].trim() === "" + ) { + compacted.pop(); + } + return compacted.length > 0 ? `${compacted.join("\n")}\n` : ""; +} diff --git a/packages/cli/src/utils/codex-version.ts b/packages/cli/src/utils/codex-version.ts new file mode 100644 index 0000000..0748f5d --- /dev/null +++ b/packages/cli/src/utils/codex-version.ts @@ -0,0 +1,109 @@ +import { execFileSync } from "node:child_process"; + +export const MIN_CODEX_VERSION = "0.144.0"; + +export interface CodexVersionProbe { + status: "available" | "missing" | "unreadable"; + raw: string | null; + version: string | null; + supported: boolean; +} + +export type CodexVersionRunner = () => string; + +const VERSION_PATTERN = + /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?)/; + +function defaultRunner(): string { + return String( + execFileSync("codex", ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }), + ); +} + +export function parseCodexVersion(output: string): string | null { + const match = output.match(VERSION_PATTERN); + return match?.[1] ?? null; +} + +function isSupportedCodexVersion(version: string): boolean { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) return false; + const current = [Number(match[1]), Number(match[2]), Number(match[3])]; + const minimum = [0, 144, 0]; + for (let index = 0; index < current.length; index += 1) { + if (current[index] > minimum[index]) return true; + if (current[index] < minimum[index]) return false; + } + return !match[4]; +} + +/** Probe `codex --version` without shelling out or contacting a network. */ +export function probeCodexVersion( + runner: CodexVersionRunner = defaultRunner, +): CodexVersionProbe { + try { + const raw = runner().trim(); + const version = parseCodexVersion(raw); + return { + status: version ? "available" : "unreadable", + raw: raw || null, + version, + supported: version ? isSupportedCodexVersion(version) : false, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { + status: code === "ENOENT" ? "missing" : "unreadable", + raw: null, + version: null, + supported: false, + }; + } +} + +let cachedProbe: CodexVersionProbe | undefined; + +/** Cache the host probe for one CLI process so configuration never probes per turn. */ +export function getCodexVersion(): CodexVersionProbe { + cachedProbe ??= probeCodexVersion(); + return cachedProbe; +} + +export function resetCodexVersionProbeForTests(): void { + cachedProbe = undefined; +} + +export function codexVersionWarning(probe: CodexVersionProbe): string | null { + if (probe.status === "missing") { + return ( + "Codex was not found on PATH. " + + `Codex ${MIN_CODEX_VERSION} or newer is needed to recognize Trellis model and reasoning-effort defaults.` + ); + } + if (probe.status === "unreadable") { + return ( + "Codex version could not be read. " + + `Codex ${MIN_CODEX_VERSION} or newer is needed to recognize Trellis model and reasoning-effort defaults.` + ); + } + if (!probe.supported) { + return ( + `Codex ${probe.version} is below ${MIN_CODEX_VERSION}. ` + + "Trellis will continue, but this Codex version may not recognize the configured model and reasoning-effort defaults." + ); + } + return null; +} + +export function warnCodexVersion(): void { + if (process.env.VITEST || process.env.TRELLIS_QUIET) return; + const warning = codexVersionWarning(getCodexVersion()); + if (warning) { + process.stderr.write(`Warning: ${warning}\n`); + } +} diff --git a/packages/cli/src/utils/uninstall-scrubbers.ts b/packages/cli/src/utils/uninstall-scrubbers.ts index e654bfe..241bd7d 100644 --- a/packages/cli/src/utils/uninstall-scrubbers.ts +++ b/packages/cli/src/utils/uninstall-scrubbers.ts @@ -12,6 +12,120 @@ * does not matter — we just look for the manifest-relative file path. */ +import { stripTrellisCodexConfig } from "./codex-config.js"; + +const LEGACY_CODEX_COMMENTS = new Set([ + "Project-scoped Codex defaults for Trellis workflows.", + "Codex loads this after ~/.codex/config.toml when you work in this project.", + "Codex merges this layer after the user-level config when the project", + "is marked as a trusted project. To trust this project, add it under", + "`[projects]` in ~/.codex/config.toml, e.g.:", + '[projects."/abs/path/to/this/repo"]', + 'trust_level = "trusted"', + "Without trust, the [features] block below is loaded but disabled.", + "Default coordinator model for this research workflow.", + "Default coordinator model for this research workflow. Codex 0.144.0 or", + "newer is required to recognize these model and reasoning-effort values.", + "Keep AGENTS.md as the primary project instruction file.", + "NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.", + "Add the following to your USER-level config at ~/.codex/config.toml", + "(not this project file - features.* must be enabled globally):", + "[features]", + "hooks = true", + "codex_hooks = true", + "Without this flag, hooks.json is ignored and Trellis context won't", + "be injected into Codex sessions.", + "Codex hooks (`hooks.json` in this directory) only fire when the user", + "has enabled them in their USER-level config: `[features].hooks = true`", + "in ~/.codex/config.toml (Codex 0.129+; legacy name: `codex_hooks = true`,", + "still works but emits a deprecation warning on 0.129+). Project-level", + "config.toml cannot set feature flags; they must be user-level.", + "Codex 0.129+ additionally gates each installed hook behind a one-time", + "`/hooks` TUI review; until the user approves it, the hook stays inactive.", + "multi_agent_v2 forces structured subagent orchestration with the", + "instead of cancelling / re-spawning. Incompatible with", + "[agents].max_threads (codex will reject the combination).", + "NOT auto-enable the feature without it.", + "report progress through its task record instead of holding the coordinator.", + "short for Trellis subagents that routinely take 2-10 min. Hard", + "ceiling is 3,600,000 (1 h).", + "Native custom-agent dispatch cannot mechanically replace or validate the", + "agent prompt/output, so its Context Firewall authority is advisory only.", + "Use the generated dispatch CLI strict wrapper for enforced Hermes work:", + "python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id --platform codex --mode strict", + "Native custom-agent dispatch uses the validated Hermes dispatch and Result", + "Envelope files. It works directly in the current project workspace.", +]); + +const LEGACY_CODEX_TABLE_VALUES = new Map>([ + [ + "[features.multi_agent_v2]", + new Set([ + "enabled = true", + "max_concurrent_threads_per_session = 6", + "min_wait_timeout_ms = 480000", + "max_concurrent_threads_per_session = 3", + "min_wait_timeout_ms = 120000", + ]), + ], + [ + "[hermes.context_firewall]", + new Set([ + 'native_authority = "advisory"', + 'strict_authority = "enforced"', + 'strict_runtime = "codex exec --output-schema --json -o"', + 'native_authority = "protocol"', + ]), + ], +]); + +function codexCommentText(line: string): string | null { + const trimmed = line.trim(); + return trimmed.startsWith("#") ? trimmed.replace(/^#+\s?/, "").trim() : null; +} + +function isLegacyCodexComment(comment: string | null): boolean { + if (comment === null) return false; + return ( + comment === "" || + LEGACY_CODEX_COMMENTS.has(comment) || + comment.startsWith("(not this project file") || + comment.startsWith("`wait` tool") || + comment.startsWith("`enabled = true`") || + comment.startsWith("- max_concurrent_threads_per_session:") || + comment.startsWith("- min_wait_timeout_ms:") + ); +} + +function legacyCodexTableEnd(lines: string[], start: number): number | null { + const allowedValues = LEGACY_CODEX_TABLE_VALUES.get(lines[start].trim()); + if (!allowedValues) return null; + + let end = start + 1; + let hasValue = false; + while ( + end < lines.length && + !/^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/.test(lines[end]) + ) { + const trimmed = lines[end].trim(); + if (trimmed.length === 0) { + end += 1; + continue; + } + const comment = codexCommentText(lines[end]); + if (comment !== null) { + if (!isLegacyCodexComment(comment)) return null; + } else if (allowedValues.has(trimmed)) { + hasValue = true; + } else { + return null; + } + end += 1; + } + + return hasValue ? end : null; +} + export interface ScrubResult { content: string; fullyEmpty: boolean; @@ -305,73 +419,39 @@ export function scrubPiSettings(content: string): ScrubResult { /** * Scrub `.codex/config.toml`. * - * The current trellis-emitted file has two distinct chunks: - * 1. The line `project_doc_fallback_filenames = ["AGENTS.md"]` - * 2. A multi-line comment block that begins with the marker - * `# NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.` - * and continues through `# be injected into Codex sessions.` - * - * Plus the leading "Project-scoped Codex defaults" header comments. - * - * Strategy: line-based removal. We strip: - * - the `project_doc_fallback_filenames = ...` line - * - any line that is *only* a comment introduced by trellis (the entire file - * as shipped is comments + that one assignment) - * - blank lines that surrounded those removals - * - * If the user added their own non-trellis lines, they are preserved as-is. - * "Fully empty" = post-scrub content has no non-whitespace characters. + * Explicit Trellis marker blocks are removed as units. A custom model inside + * those blocks is retained. Older unmarked Trellis defaults are recognized by + * their exact values, so user-owned configuration stays intact. */ export function scrubCodexConfigToml(content: string): ScrubResult { - const trellisCommentMarkers = [ - "Project-scoped Codex defaults for Trellis workflows.", - "Codex loads this after ~/.codex/config.toml when you work in this project.", - "Keep AGENTS.md as the primary project instruction file.", - "NOTE: Trellis's SessionStart + UserPromptSubmit hooks require opt-in.", - "Add the following to your USER-level config at ~/.codex/config.toml", - "(not this project file — features.* must be enabled globally):", - "[features]", - "hooks = true", - "codex_hooks = true", - "Without this flag, hooks.json is ignored and Trellis context won't", - "be injected into Codex sessions.", - ]; + const stripped = stripTrellisCodexConfig(content); + const lines = stripped.split(/\r?\n/); + const kept: string[] = []; + let previousBlank = true; + for (let index = 0; index < lines.length; index += 1) { + const tableEnd = legacyCodexTableEnd(lines, index); + if (tableEnd !== null) { + index = tableEnd - 1; + continue; + } - // A comment line is "trellis-known" if its content (after `#` and spaces) - // matches one of the known marker strings exactly OR is an empty `#` line. - function isTrellisCommentLine(line: string): boolean { + const line = lines[index]; const trimmed = line.trim(); - if (!trimmed.startsWith("#")) return false; - const inner = trimmed.replace(/^#+\s?/, "").trim(); - if (inner.length === 0) return true; // bare `#` line inside trellis block - return trellisCommentMarkers.some((m) => inner === m); - } - - function isTrellisAssignment(line: string): boolean { - return /^\s*project_doc_fallback_filenames\s*=/.test(line); - } - - const out: string[] = []; - let prevWasBlank = true; // start-of-file counts as blank for collapsing - - for (const rawLine of content.split(/\r?\n/)) { - if (isTrellisAssignment(rawLine) || isTrellisCommentLine(rawLine)) { - continue; // drop - } - const isBlank = rawLine.trim().length === 0; - if (isBlank && prevWasBlank) { - continue; // collapse runs of blanks created by removals - } - out.push(rawLine); - prevWasBlank = isBlank; + const comment = codexCommentText(line); + const trellisLegacyLine = + /^\s*project_doc_fallback_filenames\s*=\s*\[\s*"AGENTS\.md"\s*\]\s*$/.test( + line, + ) || isLegacyCodexComment(comment); + if (trellisLegacyLine) continue; + const blank = trimmed.length === 0; + if (blank && previousBlank) continue; + kept.push(line); + previousBlank = blank; } - - // Trim trailing blank lines. - while (out.length > 0 && out[out.length - 1].trim().length === 0) { - out.pop(); + while (kept.length > 0 && kept[kept.length - 1].trim() === "") { + kept.pop(); } - - const result = out.length > 0 ? out.join("\n") + "\n" : ""; + const result = kept.length > 0 ? `${kept.join("\n")}\n` : ""; const fullyEmpty = result.trim().length === 0; return { content: result, fullyEmpty }; } diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index 54b9550..381f499 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -345,6 +345,49 @@ describe("update() integration", () => { expect(fs.readFileSync(targetFull, "utf-8")).toBe(templateContent); }); + it("updates managed Codex settings without resetting a user model", async () => { + await init({ yes: true, force: true, codex: true }); + + const configRelative = ".codex/config.toml"; + const configPath = projectFile(configRelative); + const customized = readProjectFile(configRelative) + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + ); + fs.writeFileSync(configPath, customized); + + await update({}); + + const updated = fs.readFileSync(configPath, "utf-8"); + expect(updated).toContain('model = "gpt-5.6-custom"'); + expect(updated).toContain("max_concurrent_threads_per_session = 3"); + expect(readHashesV2(hashFilePath())[configRelative]).toBe( + computeHash(updated), + ); + }); + + it("migrates the legacy Codex default model pair during update", async () => { + await init({ yes: true, force: true, codex: true }); + + const configRelative = ".codex/config.toml"; + const configPath = projectFile(configRelative); + const legacy = readProjectFile(configRelative) + .replace("# TRELLIS:CODEX_CONFIG:START\n", "") + .replace("\n# TRELLIS:CODEX_CONFIG:END", "") + .replace("# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", "") + .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", ""); + fs.writeFileSync(configPath, legacy); + + await update({}); + + const migrated = fs.readFileSync(configPath, "utf-8"); + expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:START"); + expect(migrated).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + expect(migrated).not.toContain("# TRELLIS:CODEX_CONFIG:START"); + }); + it("#4b auto-updates Claude implement agent when the installed template is older but unmodified", async () => { await init({ yes: true, force: true, claude: true }); diff --git a/packages/cli/test/configurators/platforms.test.ts b/packages/cli/test/configurators/platforms.test.ts index 6471eef..e63d270 100644 --- a/packages/cli/test/configurators/platforms.test.ts +++ b/packages/cli/test/configurators/platforms.test.ts @@ -335,6 +335,27 @@ describe("configurePlatform", () => { ); }); + it("configurePlatform('codex') preserves a user-selected model in managed config", async () => { + const config = getCodexConfigTemplate(); + const configPath = path.join(tmpDir, ".codex", config.targetPath); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + config.content + .replace('model = "gpt-5.6-sol"', 'model = "gpt-5.6-custom"') + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + ), + ); + + await configurePlatform("codex", tmpDir); + + const written = fs.readFileSync(configPath, "utf-8"); + expect(written).toContain('model = "gpt-5.6-custom"'); + expect(written).toContain("max_concurrent_threads_per_session = 3"); + }); + it("configurePlatform('codex') resolves PYTHON_CMD in hooks.json", async () => { await configurePlatform("codex", tmpDir); diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index fa81ab6..8ec4251 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -301,7 +301,11 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(create("job-project-coder", "coder").status).toBe(0); expect(create("job-project-runner", "runner").status).toBe(0); expect( - create("job-project-review", "reviewer", ["--profile", "evidence"]) + create("job-project-review", "reviewer", [ + "--profile", "evidence", + "--work-package", "WP1", + "--parent-job-id", "job-project-runner", + ]) .status, ).toBe(0); expect( @@ -344,7 +348,9 @@ describe("Agent Context Firewall dispatch CLI", () => { it("allows planner and reviewer task-level work without a package", () => { expect(create("job-planner", "planner").status).toBe(0); - expect(create("job-review", "reviewer").status).toBe(0); + expect( + create("job-review", "reviewer", ["--profile", "closure"]).status, + ).toBe(0); const review = JSON.parse( fs.readFileSync( path.join(taskDir, "hermes", "dispatches", "job-review.dispatch.json"), @@ -631,8 +637,13 @@ describe("Agent Context Firewall dispatch CLI", () => { }); it("limits evidence and claim reviewers to proposed judgments", () => { + expect(create("job-judgment-source", "runner").status).toBe(0); expect( - create("job-evidence-judgment", "reviewer", ["--profile", "evidence"]).status, + create("job-evidence-judgment", "reviewer", [ + "--profile", "evidence", + "--work-package", "WP1", + "--parent-job-id", "job-judgment-source", + ]).status, ).toBe(0); const approved = apply( "job-evidence-judgment", @@ -644,7 +655,11 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(approved.stderr).toContain("review_authority_violation"); expect( - create("job-claim-judgment", "reviewer", ["--profile", "claim"]).status, + create("job-claim-judgment", "reviewer", [ + "--profile", "claim", + "--work-package", "WP1", + "--parent-job-id", "job-judgment-source", + ]).status, ).toBe(0); const proposed = apply( "job-claim-judgment", @@ -823,7 +838,14 @@ describe("Agent Context Firewall dispatch CLI", () => { }); it("binds blind reviewer reads to allowed refs and rejects worker context", () => { - expect(create("job-review-read", "reviewer", ["--profile", "quality"]).status).toBe(0); + expect(create("job-review-source", "coder").status).toBe(0); + expect( + create("job-review-read", "reviewer", [ + "--profile", "quality", + "--work-package", "WP1", + "--parent-job-id", "job-review-source", + ]).status, + ).toBe(0); runHook("inject-subagent-context.py", { cwd: root, tool_name: "Agent", diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index ed4d288..3d25b22 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -707,7 +707,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); - it("updates a handoff before an open task session finishes", () => { + it("finishes a session without mutating an open task or writing a handoff", () => { expect(plan(root, ["Result A"]).status).toBe(0); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); fs.mkdirSync(sessions, { recursive: true }); @@ -717,8 +717,9 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { ); const result = runTask(root, "finish"); expect(result.status).toBe(0); - expect(result.stdout).toContain("Handoff updated"); - expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(true); + expect(result.stdout).not.toContain("Handoff updated"); + expect(fs.existsSync(path.join(taskDir, "HANDOFF.md"))).toBe(false); + expect(readTask(taskDir).closure_state).toBe("open"); }); it("writes handoffs from validated task results only", () => { @@ -764,6 +765,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(run(root, "handoff").status).toBe(0); const handoff = fs.readFileSync(path.join(taskDir, "HANDOFF.md"), "utf-8"); expect(handoff).toContain("# Task Handoff"); + expect(handoff).toContain(`## Task Revision\n${readTask(taskDir).hermes_revision}`); expect(handoff).not.toContain("- src/owned.ts"); expect(handoff).not.toContain("src/forged.ts"); expect(handoff).not.toContain("unrelated.txt"); diff --git a/packages/cli/test/templates/codex-packaging.test.ts b/packages/cli/test/templates/codex-packaging.test.ts new file mode 100644 index 0000000..3cb0fbe --- /dev/null +++ b/packages/cli/test/templates/codex-packaging.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, "../../../.."); +const cliRoot = path.join(repoRoot, "packages", "cli"); + +describe("codex template packaging", () => { + const temporaryRoots: string[] = []; + + afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("copies config.toml with its managed markers into the package template tree", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-codex-pack-")); + temporaryRoots.push(root); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.cpSync( + path.join(cliRoot, "src", "templates"), + path.join(root, "src", "templates"), + { recursive: true }, + ); + fs.cpSync( + path.join(cliRoot, "src", "migrations", "manifests"), + path.join(root, "src", "migrations", "manifests"), + { recursive: true }, + ); + fs.copyFileSync( + path.join(cliRoot, "scripts", "copy-templates.js"), + path.join(root, "scripts", "copy-templates.js"), + ); + + execFileSync(process.execPath, ["scripts/copy-templates.js"], { + cwd: root, + stdio: "pipe", + }); + + const copied = fs.readFileSync( + path.join(root, "dist", "templates", "codex", "config.toml"), + "utf-8", + ); + expect(copied).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(copied).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + }); +}); diff --git a/packages/cli/test/templates/codex.test.ts b/packages/cli/test/templates/codex.test.ts index 19a00a6..30c789c 100644 --- a/packages/cli/test/templates/codex.test.ts +++ b/packages/cli/test/templates/codex.test.ts @@ -117,6 +117,10 @@ describe("codex getConfigTemplate", () => { expect(config.content).toContain("AGENTS.md"); expect(config.content).toContain('model = "gpt-5.6-sol"'); expect(config.content).toContain('model_reasoning_effort = "high"'); + expect(config.content).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(config.content).toContain("# TRELLIS:CODEX_CONFIG:END"); + expect(config.content).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:START"); + expect(config.content).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); expect(config.content).toContain('native_authority = "protocol"'); expect(config.content).not.toContain("strict_authority"); }); diff --git a/packages/cli/test/templates/hermes-runtime.test.ts b/packages/cli/test/templates/hermes-runtime.test.ts index 570c65a..471f94a 100644 --- a/packages/cli/test/templates/hermes-runtime.test.ts +++ b/packages/cli/test/templates/hermes-runtime.test.ts @@ -142,6 +142,8 @@ function markDataExploration(taskDir: string): void { writeJson(path.join(taskDir, "task.json"), { id: "01-test", status: "in_progress", + closure_state: "open", + hermes_phase: "running", research_route: "exploration", research_change_fields: ["dataset"], }); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 0eafad7..34272f0 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -726,7 +726,7 @@ describe("shared-hooks capability table", () => { fs.writeFileSync( path.join(taskDir, "HANDOFF.md"), - "\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + "# Task Handoff\n\n## Task Revision\n0\n\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", ); const repeated = JSON.parse(runWorkflowState(repoRoot, contextId)) as { @@ -758,6 +758,26 @@ describe("shared-hooks capability table", () => { "/HANDOFF.md", ); + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "# Legacy Task Handoff\n\nLEGACY_HANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + const legacyHandoff = JSON.parse(runWorkflowState(repoRoot, contextId)) as { + hookSpecificOutput: { additionalContext: string }; + }; + expect(legacyHandoff.hookSpecificOutput.additionalContext).toContain( + "Legacy HANDOFF.md has no Task Revision", + ); + expect(legacyHandoff.hookSpecificOutput.additionalContext).not.toContain( + "LEGACY_HANDOFF_BODY_MUST_NOT_BE_INJECTED", + ); + + fs.writeFileSync( + path.join(taskDir, "HANDOFF.md"), + "# Task Handoff\n\n## Task Revision\n0\n\nHANDOFF_BODY_MUST_NOT_BE_INJECTED\n", + ); + runWorkflowState(repoRoot, contextId); + const compacted = JSON.parse(runWorkflowState(repoRoot, contextId, { source: "compact", })) as { hookSpecificOutput: { additionalContext: string } }; @@ -778,7 +798,7 @@ describe("shared-hooks capability table", () => { expect(revised.hookSpecificOutput.additionalContext).toContain( "", ); - expect(revised.hookSpecificOutput.additionalContext).not.toContain( + expect(revised.hookSpecificOutput.additionalContext).toContain( "HANDOFF.md is stale", ); @@ -1205,12 +1225,20 @@ describe.skipIf(!hasPython())( fs.writeFileSync(path.join(repoRoot, "docs", name), name, "utf-8"); } const created = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "review-source", + "--role", "researcher", "--profile", "codebase", + "--work-package", "WP1", + "--objective", "Locate the bounded evidence inputs.", + ]); + expect(created.status, created.stderr).toBe(0); + const reviewCreated = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "review-evidence", "--role", "reviewer", "--profile", "evidence", + "--work-package", "WP1", "--parent-job-id", "review-source", "--objective", "Review the current package evidence.", "--ref", "docs/direct.md", "--ref", "docs/second.md", "--ref", "docs/third.md", ]); - expect(created.status, created.stderr).toBe(0); + expect(reviewCreated.status, reviewCreated.stderr).toBe(0); const contextId = setSessionActiveTask(repoRoot, taskName, "hermes-role-session"); const result = runPreToolUseHook( repoRoot, @@ -1230,7 +1258,8 @@ describe.skipIf(!hasPython())( const prompt = String(result.hookSpecificOutput?.updatedInput?.prompt ?? ""); expect(prompt).toContain("Hermes Agent Context Firewall dispatch"); expect(prompt).toContain("role: reviewer:evidence"); - expect(prompt).toContain("work_package: null"); + expect(prompt).toContain("work_package: WP1"); + expect(prompt).toContain("parent_job_id: review-source"); expect(prompt).toContain("docs/direct.md"); expect(prompt).not.toContain("docs/fourth.md"); expect(prompt).not.toContain("FULL_PRD_MUST_NOT_BE_INJECTED"); @@ -1253,9 +1282,16 @@ describe.skipIf(!hasPython())( current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + const source = runDispatchCli(repoRoot, [ + "create", "--task", taskName, "--job-id", "legacy-source", + "--role", "researcher", "--profile", "codebase", + "--objective", "Locate the bounded evidence inputs.", + ]); + expect(source.status, source.stderr).toBe(0); const created = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "legacy-evidence", "--role", "reviewer", "--profile", "evidence", + "--parent-job-id", "legacy-source", "--objective", "Evaluate evidence.", ]); expect(created.status, created.stderr).toBe(0); @@ -1743,6 +1779,86 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(decision.permissionDecisionReason).toContain("main agent firewall"); } + function runDispatchCli(...args: string[]) { + return spawnSync( + PYTHON, + [ + path.join(repoRoot, ".trellis", "scripts", "hermes", "dispatch.py"), + ...args, + "--task", + taskName, + ], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + TRELLIS_HOOKS_ACTIVE: "1", + TRELLIS_PLATFORM: "claude", + }, + }, + ); + } + + function applyDispatchResult( + jobId: string, + role: string, + profile: string, + parentJobId?: string, + ) { + const dispatchPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "dispatches", + `${jobId}.dispatch.json`, + ); + const dispatch = JSON.parse(fs.readFileSync(dispatchPath, "utf-8")) as { + task_revision: number; + }; + const envelope: Record = { + schema: "hermes-result/v1", + job_id: jobId, + task_revision: dispatch.task_revision, + role, + profile, + status: "success", + conclusion: `${jobId} completed`, + uncertainties: [], + changed_files: [], + evidence_refs: [], + artifact_refs: [], + verification: { run_refs: [] }, + risks: [], + next_action: "review", + }; + if (parentJobId) envelope.parent_job_id = parentJobId; + return runDispatchCli( + "apply", + "--job-id", + jobId, + "--result-json", + JSON.stringify(envelope), + ); + } + + function markClosureClosed(): void { + const taskPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "task.json", + ); + const task = JSON.parse(fs.readFileSync(taskPath, "utf-8")); + task.closure_state = "closed"; + task.hermes_phase = "closed"; + task.status = "completed"; + fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + } + it("blocks Stop when an active task has no Hermes worker records", () => { const result = runHermesRuntimeGuard(repoRoot, { cwd: repoRoot, @@ -1980,6 +2096,145 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.stderr).toBe(""); }); + it("uses an explicit dispatch parent chain from researcher results through Stop", () => { + const researcher = runDispatchCli( + "create", + "--job-id", + "job-research", + "--role", + "researcher", + "--profile", + "codebase", + "--objective", + "Inspect the bounded implementation context", + ); + expect(researcher.status, researcher.stderr).toBe(0); + const researcherResult = applyDispatchResult( + "job-research", + "researcher", + "codebase", + ); + expect(researcherResult.status, researcherResult.stderr).toBe(0); + + const reviewer = runDispatchCli( + "create", + "--job-id", + "job-review", + "--role", + "reviewer", + "--profile", + "quality", + "--parent-job-id", + "job-research", + "--objective", + "Review the exact researcher result", + ); + expect(reviewer.status, reviewer.stderr).toBe(0); + const reviewerResult = applyDispatchResult( + "job-review", + "reviewer", + "quality", + "job-research", + ); + expect(reviewerResult.status, reviewerResult.stderr).toBe(0); + + markClosureClosed(); + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + expect(stop.stdout).toBe(""); + + const records = fs.readFileSync( + path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "worker_records.jsonl", + ), + "utf-8", + ); + expect(records).toContain('"parent_job_id":"job-research"'); + }); + + it("warns when one legacy same-package parent candidate is used", () => { + markClosureClosed(); + writeHermesWorkerRecords([ + taskCard("job-research", "researcher", { + profile: "codebase", + work_package: "WP1", + }), + checkpoint("job-research", "research-complete"), + resultRecord("job-research", "research complete", { + changed_files: [], + }), + taskCard("job-review", "reviewer", { + profile: "quality", + work_package: "WP1", + }), + checkpoint("job-review", "review-complete"), + resultRecord("job-review", "review complete", { + changed_files: [], + }), + ]); + + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + const decision = parseDecision(stop); + expect(decision.additionalContext).toContain("compatibility warning"); + expect(decision.additionalContext).toContain("sole non-reviewer candidate"); + expect(decision.additionalContext).toContain("parent_job_id"); + }); + + it("blocks ambiguous legacy reviewer records instead of guessing by time", () => { + markClosureClosed(); + writeHermesWorkerRecords([ + taskCard("job-research", "researcher", { + profile: "codebase", + work_package: "WP1", + }), + checkpoint("job-research", "research-complete"), + resultRecord("job-research", "research complete", { + changed_files: [], + }), + taskCard("job-plan", "planner", { + profile: "task_planning", + work_package: "WP1", + }), + checkpoint("job-plan", "plan-complete"), + resultRecord("job-plan", "plan complete", { changed_files: [] }), + taskCard("job-review", "reviewer", { + profile: "quality", + work_package: "WP1", + }), + checkpoint("job-review", "review-complete"), + resultRecord("job-review", "review complete", { + changed_files: [], + }), + ]); + + const stop = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "Stop", + session_id: "test-session", + }); + expect(stop.status).toBe(0); + const payload = JSON.parse(stop.stdout) as { + decision?: string; + reason?: string; + }; + expect(payload.decision).toBe("block"); + expect(payload.reason).toContain("related independent reviewer record"); + }); + it("denies main-agent Write before worker file-boundary checks", () => { writeHermesWorkerRecords([ taskCard("job-coder", "coder"), @@ -2067,11 +2322,31 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("allows parameterized Hermes control-plane commands for the main agent", () => { + it("allows the bounded main-agent control and Git read matrix", () => { for (const command of [ "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo --role coder --objective bounded", - "python3 ./.trellis/scripts/closure.py audit --task demo", + "python3 ./.trellis/scripts/hermes/dispatch.py supersede --task demo --job-id old --reason stale", "python3 ./.trellis/scripts/task.py archive demo --no-commit", + "python3 ./.trellis/scripts/task.py set-scope demo local", + "python3 ./.trellis/scripts/closure.py plan --task demo --intent bounded", + "python3 ./.trellis/scripts/closure.py route --task demo --route delivery", + "python3 ./.trellis/scripts/closure.py grill --task demo --complete --decision-ref design.md", + "python3 ./.trellis/scripts/closure.py validate --task demo", + "python3 ./.trellis/scripts/closure.py status --task demo", + "python3 ./.trellis/scripts/closure.py next --task demo", + "python3 ./.trellis/scripts/closure.py capsule --task demo", + "python3 ./.trellis/scripts/closure.py package-start --task demo", + "python3 ./.trellis/scripts/closure.py package-check --task demo", + "python3 ./.trellis/scripts/closure.py package-done --task demo", + "python3 ./.trellis/scripts/closure.py package-block --task demo --reason blocked", + "python3 ./.trellis/scripts/closure.py amend --task demo --field intent --value bounded --reason update", + "python3 ./.trellis/scripts/closure.py repair --task demo", + "python3 ./.trellis/scripts/closure.py audit --task demo", + "python3 ./.trellis/scripts/closure.py close --task demo", + "git branch --show-current", + "git status --short -- src/app.ts", + "git diff --name-only -- src/app.ts", + "git log --oneline -n5", ]) { const result = runHermesRuntimeGuard(repoRoot, { cwd: repoRoot, @@ -2083,12 +2358,6 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); expect(result.stdout).toBe(""); } - expectMainAgentBashDenied( - "python3 ./.trellis/scripts/hermes/dispatch.py create --task demo; touch src/app.ts", - ); - expectMainAgentBashDenied( - "python3 ./.trellis/scripts/closure.py handoff --task demo", - ); }); it("allows main-agent git status short paths as read-only Bash", () => { @@ -2127,9 +2396,24 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { it("denies main-agent execution and mutating git Bash commands", () => { for (const command of [ "rm -rf dist", + "npm test", + "pnpm build", "git add src/app.ts", "git commit -m test", "git push", + "git diff --output=diff.txt", + "git diff --ext-diff", + "git diff --no-index left right", + "git diff --name-only -- .env.local", + "git status --short -- .envrc", + "cat .trellis/tasks/demo/task.json", + "jq . .trellis/tasks/demo/task.json", + "python3 ./.trellis/scripts/hermes/experiment.py validate --task demo", + "python3 ./.trellis/scripts/hermes/runner.py run --task demo", + "python3 ./.trellis/scripts/closure.py handoff --task demo", + "python3 ./.trellis/scripts/task.py archive demo", + "python3 ./.trellis/scripts/closure.py audit --task demo > audit.txt", + "python3 ./.trellis/scripts/closure.py audit --task demo && touch src/app.ts", "pytest", "go test ./...", "cargo test", @@ -2325,7 +2609,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { ); }); - it("lets runner Bash execution pass through existing record policy", () => { + it("allows runner Bash only through the registered runner command", () => { writeHermesWorkerRecords([ taskCard("job-runner", "runner"), heartbeat("job-runner"), @@ -2337,7 +2621,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { agent_role: "runner", tool_name: "Bash", tool_input: { - command: "pnpm test", + command: `python3 ./.trellis/scripts/hermes/runner.py validate --task ${taskName}`, }, session_id: "test-session", }); @@ -2353,6 +2637,16 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(payload.hookSpecificOutput?.additionalContext).toContain( "Hermes Runtime", ); + + const direct = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "runner", + tool_name: "Bash", + tool_input: { command: "pnpm test" }, + session_id: "test-session", + }); + expect(parseDecision(direct).permissionDecision).toBe("deny"); }); it("denies PreToolUse write tools when an active task has no Hermes worker records", () => { diff --git a/packages/cli/test/utils/codex-config.test.ts b/packages/cli/test/utils/codex-config.test.ts new file mode 100644 index 0000000..4260886 --- /dev/null +++ b/packages/cli/test/utils/codex-config.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { getConfigTemplate } from "../../src/templates/codex/index.js"; +import { + CODEX_CONFIG_BLOCK_END, + CODEX_CONFIG_BLOCK_START, + CODEX_MODEL_BLOCK_END, + CODEX_MODEL_BLOCK_START, + isTrellisCodexConfigMerge, + mergeTrellisCodexConfig, + stripTrellisCodexConfig, +} from "../../src/utils/codex-config.js"; + +const TEMPLATE = getConfigTemplate().content; + +function withoutMarkers(content: string): string { + return content + .replace(`${CODEX_CONFIG_BLOCK_START}\n`, "") + .replace(`\n${CODEX_CONFIG_BLOCK_END}`, "") + .replace(`${CODEX_MODEL_BLOCK_START}\n`, "") + .replace(`\n${CODEX_MODEL_BLOCK_END}`, ""); +} + +describe("mergeTrellisCodexConfig", () => { + it("refreshes managed settings without replacing a user-selected model", () => { + const existing = `${TEMPLATE.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ) + .replace( + 'model_reasoning_effort = "high"', + 'model_reasoning_effort = "medium"', + ) + .replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 1", + )} +[local] +keep = true +`; + const nextTemplate = TEMPLATE.replace( + "max_concurrent_threads_per_session = 3", + "max_concurrent_threads_per_session = 4", + ); + + const merged = mergeTrellisCodexConfig(existing, nextTemplate); + + expect(merged.content).toContain('model = "gpt-5.6-custom"'); + expect(merged.content).toContain('model_reasoning_effort = "medium"'); + expect(merged.content).toContain("max_concurrent_threads_per_session = 4"); + expect(merged.content).toContain("[local]\nkeep = true"); + expect(merged.migratedLegacyDefaults).toBe(false); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); + }); + + it("migrates only the exact legacy default pair", () => { + const legacy = withoutMarkers(TEMPLATE); + const migrated = mergeTrellisCodexConfig(legacy, TEMPLATE); + + expect(migrated.migratedLegacyDefaults).toBe(true); + expect(migrated.content).toContain(CODEX_MODEL_BLOCK_START); + expect(migrated.content).toContain(CODEX_MODEL_BLOCK_END); + expect(migrated.content).not.toContain(CODEX_CONFIG_BLOCK_START); + expect(isTrellisCodexConfigMerge(legacy, migrated.content)).toBe(true); + }); + + it("leaves an unmarked user model untouched", () => { + const existing = `model = "my-project-model" +model_reasoning_effort = "low" + +[project] +name = "example" +`; + + expect(mergeTrellisCodexConfig(existing, TEMPLATE)).toEqual({ + content: existing, + changed: false, + migratedLegacyDefaults: false, + }); + }); + + it("does not alter malformed marker blocks", () => { + const existing = `${CODEX_CONFIG_BLOCK_START} +model = "my-project-model" +`; + + expect(mergeTrellisCodexConfig(existing, TEMPLATE).content).toBe(existing); + }); +}); + +describe("stripTrellisCodexConfig", () => { + it("keeps a custom model while removing the managed block", () => { + const custom = TEMPLATE.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ); + + expect(stripTrellisCodexConfig(custom)).toBe( + 'model = "gpt-5.6-custom"\nmodel_reasoning_effort = "high"\n', + ); + }); +}); diff --git a/packages/cli/test/utils/codex-version.test.ts b/packages/cli/test/utils/codex-version.test.ts new file mode 100644 index 0000000..f498361 --- /dev/null +++ b/packages/cli/test/utils/codex-version.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + codexVersionWarning, + parseCodexVersion, + probeCodexVersion, +} from "../../src/utils/codex-version.js"; + +describe("parseCodexVersion", () => { + it("reads stable, v-prefixed, and prerelease versions", () => { + expect(parseCodexVersion("codex-cli 0.144.0")).toBe("0.144.0"); + expect(parseCodexVersion("codex v0.145.0-beta.1+build.7")).toBe( + "0.145.0-beta.1+build.7", + ); + }); + + it("returns null when no semantic version is available", () => { + expect(parseCodexVersion("codex-cli development build")).toBeNull(); + }); +}); + +describe("probeCodexVersion", () => { + it("classifies supported and unsupported releases", () => { + expect(probeCodexVersion(() => "codex-cli 0.144.0")).toMatchObject({ + status: "available", + version: "0.144.0", + supported: true, + }); + expect(probeCodexVersion(() => "codex-cli 0.144.0-rc.1")).toMatchObject({ + status: "available", + version: "0.144.0-rc.1", + supported: false, + }); + expect(probeCodexVersion(() => "codex-cli 0.145.0-beta.1")).toMatchObject({ + status: "available", + version: "0.145.0-beta.1", + supported: true, + }); + }); + + it("distinguishes a missing binary from unreadable output", () => { + const missing = Object.assign(new Error("missing"), { code: "ENOENT" }); + + expect( + probeCodexVersion(() => { + throw missing; + }), + ).toMatchObject({ + status: "missing", + raw: null, + version: null, + supported: false, + }); + expect(probeCodexVersion(() => "not a version")).toMatchObject({ + status: "unreadable", + raw: "not a version", + version: null, + supported: false, + }); + }); +}); + +describe("codexVersionWarning", () => { + it("explains unsupported and unavailable installations", () => { + expect( + codexVersionWarning({ + status: "available", + raw: "codex-cli 0.143.9", + version: "0.143.9", + supported: false, + }), + ).toContain("0.143.9 is below 0.144.0"); + expect( + codexVersionWarning({ + status: "missing", + raw: null, + version: null, + supported: false, + }), + ).toContain("not found on PATH"); + }); +}); diff --git a/packages/cli/test/utils/uninstall-scrubbers.test.ts b/packages/cli/test/utils/uninstall-scrubbers.test.ts index ec03fd6..1a9380c 100644 --- a/packages/cli/test/utils/uninstall-scrubbers.test.ts +++ b/packages/cli/test/utils/uninstall-scrubbers.test.ts @@ -14,6 +14,7 @@ import { scrubPiSettings, scrubCodexConfigToml, } from "../../src/utils/uninstall-scrubbers.js"; +import { getConfigTemplate } from "../../src/templates/codex/index.js"; const CLAUDE_DELETE_PATHS = [ ".claude/hooks/session-start.py", @@ -270,7 +271,8 @@ describe("scrubHooksJson — flat schema", () => { { type: "command", bash: "python3 .github/copilot/hooks/inject-workflow-state.py", - powershell: "python3 .github/copilot/hooks/inject-workflow-state.py", + powershell: + "python3 .github/copilot/hooks/inject-workflow-state.py", timeoutSec: 5, }, ], @@ -465,4 +467,31 @@ project_doc_fallback_filenames = ["AGENTS.md"] expect(fullyEmpty).toBe(true); expect(content.trim()).toBe(""); }); + + it("preserves a custom model from the managed config block", () => { + const custom = getConfigTemplate().content.replace( + 'model = "gpt-5.6-sol"', + 'model = "gpt-5.6-custom"', + ); + + const { content, fullyEmpty } = scrubCodexConfigToml(custom); + + expect(fullyEmpty).toBe(false); + expect(content).toBe( + 'model = "gpt-5.6-custom"\nmodel_reasoning_effort = "high"\n', + ); + }); + + it("removes the current unmarked legacy Codex defaults", () => { + const legacy = getConfigTemplate() + .content.replace("# TRELLIS:CODEX_CONFIG:START\n", "") + .replace("\n# TRELLIS:CODEX_CONFIG:END", "") + .replace("# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", "") + .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", ""); + + const { content, fullyEmpty } = scrubCodexConfigToml(legacy); + + expect(fullyEmpty).toBe(true); + expect(content).toBe(""); + }); }); From 42485d26ec4b880cd374237e8d162dfa4efd82af Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Thu, 23 Jul 2026 00:30:12 +0800 Subject: [PATCH 07/13] fix(hermes): close final review gaps --- docs/manual/13-workers-and-gates.md | 3 +- docs/manual/14-workflows-and-platforms.md | 2 +- marketplace/workflows/native/workflow.md | 2 +- packages/cli/src/templates/codex/config.toml | 4 +- .../shared-hooks/hermes-runtime-guard.py | 65 +++++++ .../trellis/scripts/common/dispatch.py | 48 ++++- .../trellis/scripts/hermes/runtime.py | 15 ++ .../cli/src/templates/trellis/workflow.md | 2 +- packages/cli/src/utils/codex-config.ts | 181 +++++++++++++++--- .../test/commands/update.integration.test.ts | 4 +- .../cli/test/configurators/platforms.test.ts | 18 ++ ...agent-context-firewall.integration.test.ts | 36 +++- .../test/templates/codex-packaging.test.ts | 1 + .../cli/test/templates/shared-hooks.test.ts | 76 +++++--- packages/cli/test/utils/codex-config.test.ts | 35 +++- 15 files changed, 431 insertions(+), 61 deletions(-) diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 80c57e8..02fb0fb 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -81,7 +81,7 @@ profile: evidence ### 派发与结果防火墙 -1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)还必须用 `parent_job_id`(父工作编号)绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 +1. 每次派发先创建 dispatch(派发)。命令会绑定当前 `hermes_revision`(Hermes 修订号),并同时写入兼容的 `task_card`(任务卡)。`coder`(编码代理)和 `runner`(运行代理)必须绑定当前工作包。测试、构建或代码验证 runner 必须用 `parent_job_id`(父工作编号)绑定被检查的 coder;独立实验 runner 可以不绑定。`reviewer:quality/evidence/claim/statistics`(质量、证据、主张或统计复核)也必须绑定被审查的具体工作;`reviewer:closure/safety`(关闭或独立安全复核)可以保持任务级。 ```bash JOB="job-$(date -u +%Y%m%d-%H%M%S)-coder" @@ -123,6 +123,7 @@ python3 ./.trellis/scripts/hermes/jobs.py resume --task "$TASK" --job-id "$JOB" - 同一工作树不能同时存在多个未结束的 `coder`(编码代理)或 `runner`(运行代理); - 结果中的改动文件必须落在任务卡允许范围内,且不能命中禁止范围; +- `coder`(编码代理)可以按任务卡修改实现文件;规划、检索和复核角色只能写当前任务下各自的分析、研究或审查记录目录;`runner`(运行代理)不能通过编辑工具修改文件; - 配置了心跳间隔的结果需要已有检查点; - `runner`(运行代理)成功只接受已有 `run_refs`(运行引用),不能把成功输出写成证据; - `reviewer:evidence/claim`(证据或主张复核)只能提出判断,不能批准事实或人工记录;需要独立复核的记录必须显式绑定被审工作,旧记录只在同一工作包只有一个候选时兼容; diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 93a4c54..16d3da6 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -67,7 +67,7 @@ trust_level = "trusted" 重启 `Codex`(代码代理)后运行 `/hooks`(钩子审阅),逐项检查并批准 `.codex/hooks.json`(Codex 钩子配置)中的工作流注入和 `Hermes`(科研工作流)门禁。旧版 `Codex`(代码代理)使用 `codex_hooks = true`(旧钩子开关)。项目未信任、功能开关未开、未经 `/hooks`(钩子审阅)批准,或设置 `TRELLIS_HOOKS=0`(关闭钩子)、`TRELLIS_DISABLE_HOOKS=1`(禁用钩子)时,注入和门禁都不生效。 -项目模板为 `Codex 0.144.0`(代码代理 0.144.0 版)及以上版本设置默认模型:主代理以及 `planner/researcher/reviewer`(规划、检索和复核代理)使用 `gpt-5.6-sol`(推理模型)和 `high`(高推理强度),`coder/runner`(编码和运行代理)使用 `gpt-5.6-terra`(执行模型)和 `max`(最大推理强度)。初始化和更新只维护带 Trellis 标记的配置块;用户自行选择的项目模型会保留,卸载时也不会被当成 Trellis 默认值删除。旧版 `Codex`(代码代理)只显示兼容警告,不阻止初始化。 +项目模板为 `Codex 0.144.0`(代码代理 0.144.0 版)及以上版本设置默认模型:主代理以及 `planner/researcher/reviewer`(规划、检索和复核代理)使用 `gpt-5.6-sol`(推理模型)和 `high`(高推理强度),`coder/runner`(编码和运行代理)使用 `gpt-5.6-terra`(执行模型)和 `max`(最大推理强度)。初始化和更新只维护两个带 Trellis 标记的配置块,分别保存顶层默认值和平台表配置;未标记的用户配置会原样保留并与受管块合并,用户自行选择的项目模型在更新和卸载时都不会被当成 Trellis 默认值删除。旧版 `Codex`(代码代理)只显示兼容警告,不阻止初始化。 `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index b532462..959718d 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -288,7 +288,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 the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Quality, evidence, claim, and statistics reviews bind `parent_job_id` to the exact job under review; closure and independent safety reviews may remain task-level. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Runner commands go through `runner.py` so the run manifest records the actual execution. 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`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Test/build/code-validation runners and quality/evidence/claim/statistics reviewers bind `parent_job_id` to the exact job under review; independent experiments, closure, and independent safety review may remain task-level. Only coder edits implementation files; planner, researcher, and reviewer may write only their task-scoped record directories, and runner executes through `runner.py`. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. diff --git a/packages/cli/src/templates/codex/config.toml b/packages/cli/src/templates/codex/config.toml index 4e123bc..8584abe 100644 --- a/packages/cli/src/templates/codex/config.toml +++ b/packages/cli/src/templates/codex/config.toml @@ -26,7 +26,9 @@ project_doc_fallback_filenames = ["AGENTS.md"] # config.toml cannot set feature flags; they must be user-level. # Codex 0.129+ additionally gates each installed hook behind a one-time # `/hooks` TUI review; until the user approves it, the hook stays inactive. +# TRELLIS:CODEX_CONFIG:END +# TRELLIS:CODEX_TABLES:START # multi_agent_v2 forces structured subagent orchestration with the # `wait` tool — parent must block on terminal status before acting, # instead of cancelling / re-spawning. Incompatible with @@ -45,4 +47,4 @@ min_wait_timeout_ms = 120000 # Envelope files. It works directly in the current project workspace. [hermes.context_firewall] native_authority = "protocol" -# TRELLIS:CODEX_CONFIG:END +# TRELLIS:CODEX_TABLES:END diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 0b04a8e..30acf61 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -645,6 +645,23 @@ def declared_worker_role(data: dict[str, Any]) -> str | None: "trellis_implement": "coder", "runner": "runner", "hermes_runner": "runner", + "planner": "planner", + "hermes_planner": "planner", + "scientist": "planner", + "hermes_scientist": "planner", + "researcher": "researcher", + "hermes_researcher": "researcher", + "research_scout": "researcher", + "trellis_research": "researcher", + "reviewer": "reviewer", + "hermes_reviewer": "reviewer", + "claim_reviewer": "reviewer", + "trellis_check": "reviewer", + "trellis_spec_review": "reviewer", + "trellis_code_review": "reviewer", + "trellis_code_architecture_review": "reviewer", + "trellis_merge_review": "reviewer", + "trellis_improve_codebase_architecture": "reviewer", } for value in agent_identity_values(data): normalized = normalized_agent_identity(value) @@ -1162,6 +1179,21 @@ def guard_tool_input_permissions( records = read_worker_records(worker_records) job_id = extract_job_id_from_tool_input(tool_input) + agent_id = data.get("agent_id") + if job_id is None and isinstance(agent_id, str) and agent_id: + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.dispatch import load_dispatch_for_agent # type: ignore[import-not-found] + + bound_dispatch = load_dispatch_for_agent( + root / ".trellis" / "tasks" / task, + agent_id, + ) + job_id = str(bound_dispatch.get("job_id") or "") or None + except Exception: + return "Hermes Runtime: write caller has no valid dispatch binding." if job_id is None: candidates = unresolved_task_card_job_ids(records) if len(candidates) == 1: @@ -1172,6 +1204,39 @@ def guard_tool_input_permissions( "does not include job_id and there is no unique active task_card." ) + cards = task_cards_by_job(records, root) + card = cards.get(job_id) + if card is None: + return f"Hermes Runtime: task_card for {job_id} is missing or invalid." + role = str(card.get("role") or "") + declared_role = declared_worker_role(data) + if declared_role is not None and declared_role != role: + return ( + f"Hermes Runtime: declared {declared_role} role does not match " + f"task_card role {role or 'unknown'}." + ) + role_roots = { + "planner": [f".trellis/tasks/{task}/hermes/analysis"], + "researcher": [ + f".trellis/tasks/{task}/research", + f".trellis/tasks/{task}/hermes/research", + ], + "reviewer": [f".trellis/tasks/{task}/hermes/reviews"], + "runner": [], + } + if role != "coder": + roots = role_roots.get(role, []) + invalid_targets = [ + target + for target in target_files + if not any(target == item or target.startswith(item + "/") for item in roots) + ] + if invalid_targets: + return ( + f"Hermes Runtime: {role or 'unknown'} cannot write implementation files; " + "use the role's task-scoped record directory or delegate to coder." + ) + guard = run_runtime( root, [ diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index 3654e68..a2e5d2a 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -225,6 +225,7 @@ def create_dispatch( allowed_files, ) _validate_handoff_file_boundary(task_dir, repo_root, allowed_files, handoff_writer) + _validate_role_write_scope(task_dir, repo_root, role, allowed_files) _validate_work_package(task, role, work_package, allow_task_handoff=handoff_writer) refs = _normalize_refs( task_dir, @@ -1469,6 +1470,34 @@ def _validate_handoff_file_boundary( ) +def _validate_role_write_scope( + task_dir: Path, + repo_root: Path, + role: str, + allowed_files: list[str], +) -> None: + if role == "coder" or not allowed_files: + return + task_ref = _repo_relative(task_dir, repo_root) + roots = { + "planner": [f"{task_ref}/hermes/analysis"], + "researcher": [f"{task_ref}/research", f"{task_ref}/hermes/research"], + "reviewer": [f"{task_ref}/hermes/reviews"], + "runner": [], + }.get(role, []) + invalid = [ + pattern + for pattern in allowed_files + if not any(pattern == root or pattern.startswith(root + "/") for root in roots) + ] + if invalid: + raise DispatchError( + "role_write_scope", + f"{role} allowed_files may only target its task-scoped record directory", + invalid, + ) + + def _validate_parent_job( task_dir: Path, task: dict[str, Any], @@ -1482,11 +1511,28 @@ def _validate_parent_job( ) -> None: """Bind formal review work to one existing job in the same package.""" requires_parent = role == "reviewer" and profile not in INDEPENDENT_REVIEWER_PROFILES + if role == "runner" and profile in {"test", "build"}: + requires_parent = True + if role == "runner" and profile == "validation" and parent_job_id is None: + dispatch_dir = task_dir / "hermes" / "dispatches" + for candidate_path in dispatch_dir.glob("*.dispatch.json"): + try: + candidate = json.loads(candidate_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if ( + isinstance(candidate, dict) + and candidate.get("role") == "coder" + and candidate.get("work_package") == work_package + and candidate.get("status") != "superseded" + ): + requires_parent = True + break if parent_job_id is None: if requires_parent: raise DispatchError( "missing_parent_job", - "reviewer dispatch requires parent_job_id unless it is closure or safety review", + f"{role}:{profile} dispatch requires parent_job_id for the work being checked", ) return _validate_job_id(parent_job_id) diff --git a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py index 02ff74f..5dd5e15 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/runtime.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/runtime.py @@ -1299,6 +1299,21 @@ def _parent_job_card_errors( return [ f"line {entry.line_number}: reviewer task_card requires parent_job_id unless profile is closure or safety" ] + if role == "runner" and profile in {"test", "build"} and parent_job_id is None: + return [ + f"line {entry.line_number}: runner {profile} task_card requires parent_job_id" + ] + if role == "runner" and profile == "validation" and parent_job_id is None: + has_coder = any( + len(entries) == 1 + and entries[0].value.get("role") == "coder" + and entries[0].value.get("work_package") == card.get("work_package") + for entries in task_cards.values() + ) + if has_coder: + return [ + f"line {entry.line_number}: runner validation task_card requires parent_job_id when checking coder work" + ] if parent_job_id is None: return [] if parent_job_id == job_id: diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index b532462..959718d 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -288,7 +288,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 the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Quality, evidence, claim, and statistics reviews bind `parent_job_id` to the exact job under review; closure and independent safety reviews may remain task-level. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Runner commands go through `runner.py` so the run manifest records the actual execution. 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`. +Hermes closure: follow the Task Capsule route and its one-line rule before starting a package; exploration without a completed grill cannot validate, while delivery/execution do not require one. Create a validated revision-bound dispatch before every formal role. Claude Agent receives only `job_id`; Codex uses the same compact dispatch and result files in the current project workspace. Test/build/code-validation runners and quality/evidence/claim/statistics reviewers bind `parent_job_id` to the exact job under review; independent experiments, closure, and independent safety review may remain task-level. Only coder edits implementation files; planner, researcher, and reviewer may write only their task-scoped record directories, and runner executes through `runner.py`. Accept only the sanitized Result Envelope with `uncertainties`; never inject raw logs, diffs, or search history. Each dispatch is single-purpose: after a result is recorded, immediately close that subagent and create a fresh dispatch for later work. Move running -> review with `package-check`, record validation with `package-done`, then run `audit` and `close`. Research-first routing: code packages trigger coder `before-dev`, then runner verification and independent reviewer quality checks; formal experiment packages trigger runner records and reviewer evidence/statistics checks without forcing code skills; `update-spec` is evaluated once near closure and writes only reviewed durable knowledge. Repeated technical failures may trigger `break-loop`; negative research results do not. Handoff is conditional on pause, phase/agent change, compaction, blocking, or closure. A handoff records its task revision; stale or legacy handoffs are recovery hints only, and `task.py finish` never writes one implicitly. At closure, reviewer checks closure and runner performs final validation/archive actions. Compatibility finish path for code work: archive or commit as needed -> `/trellis:finish-work`; research-only tasks do not force a code commit. Claude Code optional review gates: if the task artifacts carry `Review-gate contract: explicit-selection-v1`, preserve the configured selection. Keep any enabled `trellis-spec-review`, `trellis-code-review`, and `trellis-code-architecture-review` in that order, run `trellis-improve-codebase-architecture` and `trellis-merge-review` only when the task strategy explicitly enables them, and keep `trellis-check` fixed outside this optional set. New tasks that use this contract must record `Optional review gates status: configured` plus explicit enabled/disabled lists; if the user picks none, record all five optional gates as disabled. Only tasks that entirely lack the contract marker count as legacy tasks and preserve the old behavior. diff --git a/packages/cli/src/utils/codex-config.ts b/packages/cli/src/utils/codex-config.ts index 1a48ce2..515786d 100644 --- a/packages/cli/src/utils/codex-config.ts +++ b/packages/cli/src/utils/codex-config.ts @@ -2,6 +2,8 @@ export const CODEX_CONFIG_BLOCK_START = "# TRELLIS:CODEX_CONFIG:START"; export const CODEX_CONFIG_BLOCK_END = "# TRELLIS:CODEX_CONFIG:END"; export const CODEX_MODEL_BLOCK_START = "# TRELLIS:CODEX_MODEL_DEFAULTS:START"; export const CODEX_MODEL_BLOCK_END = "# TRELLIS:CODEX_MODEL_DEFAULTS:END"; +export const CODEX_TABLES_BLOCK_START = "# TRELLIS:CODEX_TABLES:START"; +export const CODEX_TABLES_BLOCK_END = "# TRELLIS:CODEX_TABLES:END"; const LEGACY_MODEL_LINE = /^\s*model\s*=\s*"gpt-5\.6-sol"\s*$/; const LEGACY_REASONING_LINE = /^\s*model_reasoning_effort\s*=\s*"high"\s*$/; @@ -54,6 +56,8 @@ function hasAnyTrellisMarker(content: string): boolean { CODEX_CONFIG_BLOCK_END, CODEX_MODEL_BLOCK_START, CODEX_MODEL_BLOCK_END, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, ].some((marker) => content.includes(marker)); } @@ -202,9 +206,141 @@ function mergeWholeConfigBlock( nextBlock = preserveCustomModelBlock(nextBlock, existingBlock); } + const legacyWholeBlock = existingBlock + .split(/\r?\n/) + .some((line) => TABLE_HEADER.test(line)); + const tables = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (legacyWholeBlock && tables) { + nextBlock += `\n\n${template.slice(tables.start, tables.end)}`; + } return replaceRange(existing, current, nextBlock); } +function mergeTablesBlock(existing: string, template: string): string { + const current = markerRange( + existing, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + const replacement = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!current || !replacement) return existing; + return replaceRange( + existing, + current, + managedTablesForExisting(template, existing), + ); +} + +function tableHeaders(content: string): Set { + return new Set( + content + .split(/\r?\n/) + .filter((line) => TABLE_HEADER.test(line)) + .map((line) => line.trim()), + ); +} + +function managedTablesForExisting(template: string, existing: string): string { + const templateRange = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!templateRange) return ""; + const currentRange = markerRange( + existing, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + const external = currentRange + ? replaceRange(existing, currentRange, "") + : existing; + const existingHeaders = tableHeaders(external); + if (existingHeaders.size === 0) { + return template.slice(templateRange.start, templateRange.end); + } + + const lines = template + .slice(templateRange.start, templateRange.end) + .split(/\r?\n/); + const sections: string[][] = []; + for (let index = 0; index < lines.length; index += 1) { + if (!TABLE_HEADER.test(lines[index])) continue; + let end = index + 1; + while ( + end < lines.length && + !TABLE_HEADER.test(lines[end]) && + lines[end] !== CODEX_TABLES_BLOCK_END + ) { + end += 1; + } + if (!existingHeaders.has(lines[index].trim())) { + sections.push(lines.slice(index, end)); + } + index = end - 1; + } + return [ + CODEX_TABLES_BLOCK_START, + ...sections.flat(), + CODEX_TABLES_BLOCK_END, + ].join("\n"); +} + +function firstTableLine(lines: string[]): number { + const index = lines.findIndex((line) => TABLE_HEADER.test(line)); + return index < 0 ? lines.length : index; +} + +function withoutProjectDocDefault(block: string): string { + return block + .split(/\r?\n/) + .filter((line) => !/^\s*project_doc_fallback_filenames\s*=/.test(line)) + .join("\n"); +} + +function mergeUnmarkedConfig( + existing: string, + template: string, +): string | null { + const topRange = markerRange( + template, + CODEX_CONFIG_BLOCK_START, + CODEX_CONFIG_BLOCK_END, + ); + const tablesRange = markerRange( + template, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (!topRange || !tablesRange) return null; + + const lines = existing.split(/\r?\n/); + const tableAt = firstTableLine(lines); + const userTop = lines.slice(0, tableAt).join("\n").trim(); + const userTables = lines.slice(tableAt).join("\n").trim(); + let managedTop = template.slice(topRange.start, topRange.end); + if (topLevelModelAssignments(userTop).length > 0) { + managedTop = removeModelBlock(managedTop); + } + if (/^\s*project_doc_fallback_filenames\s*=/m.test(userTop)) { + managedTop = withoutProjectDocDefault(managedTop); + } + const managedTables = managedTablesForExisting(template, existing); + return ( + [userTop, managedTop.trim(), userTables, managedTables.trim()] + .filter(Boolean) + .join("\n\n") + "\n" + ); +} + function mergeModelDefaultsBlock( existing: string, template: string, @@ -245,9 +381,10 @@ export function mergeTrellisCodexConfig( ): CodexConfigMergeResult { const wholeBlock = mergeWholeConfigBlock(existing, template); if (wholeBlock !== null) { + const merged = mergeTablesBlock(wholeBlock, template); return { - content: wholeBlock, - changed: wholeBlock !== existing, + content: merged, + changed: merged !== existing, migratedLegacyDefaults: false, }; } @@ -273,8 +410,12 @@ export function mergeTrellisCodexConfig( migratedLegacyDefaults: true, }; } - - return { content: existing, changed: false, migratedLegacyDefaults: false }; + const merged = mergeUnmarkedConfig(existing, template); + return { + content: merged ?? existing, + changed: merged !== null && merged !== existing, + migratedLegacyDefaults: false, + }; } export function isLegacyCodexDefaultMigration( @@ -290,25 +431,10 @@ export function isTrellisCodexConfigMerge( existing: string, candidate: string, ): boolean { - const blockKinds = [ - [CODEX_CONFIG_BLOCK_START, CODEX_CONFIG_BLOCK_END], - [CODEX_MODEL_BLOCK_START, CODEX_MODEL_BLOCK_END], - ] as const; - - for (const [startMarker, endMarker] of blockKinds) { - const current = markerRange(existing, startMarker, endMarker); - const replacement = markerRange(candidate, startMarker, endMarker); - if (!current || !replacement) continue; - if ( - existing.slice(0, current.start) === - candidate.slice(0, replacement.start) && - existing.slice(current.end) === candidate.slice(replacement.end) - ) { - return true; - } - } - - return isLegacyCodexDefaultMigration(existing, candidate); + return ( + stripTrellisCodexConfig(existing) === stripTrellisCodexConfig(candidate) || + isLegacyCodexDefaultMigration(existing, candidate) + ); } function removeLegacyDefaultLines(content: string): string { @@ -362,6 +488,15 @@ export function stripTrellisCodexConfig(content: string): string { } } + const tablesBlock = markerRange( + result, + CODEX_TABLES_BLOCK_START, + CODEX_TABLES_BLOCK_END, + ); + if (tablesBlock) { + result = replaceRange(result, tablesBlock, ""); + } + result = removeLegacyDefaultLines(result); const lines = result.split(/\r?\n/); const compacted: string[] = []; diff --git a/packages/cli/test/commands/update.integration.test.ts b/packages/cli/test/commands/update.integration.test.ts index 381f499..da02b6e 100644 --- a/packages/cli/test/commands/update.integration.test.ts +++ b/packages/cli/test/commands/update.integration.test.ts @@ -377,7 +377,9 @@ describe("update() integration", () => { .replace("# TRELLIS:CODEX_CONFIG:START\n", "") .replace("\n# TRELLIS:CODEX_CONFIG:END", "") .replace("# TRELLIS:CODEX_MODEL_DEFAULTS:START\n", "") - .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", ""); + .replace("\n# TRELLIS:CODEX_MODEL_DEFAULTS:END", "") + .replace("# TRELLIS:CODEX_TABLES:START\n", "") + .replace("\n# TRELLIS:CODEX_TABLES:END", ""); fs.writeFileSync(configPath, legacy); await update({}); diff --git a/packages/cli/test/configurators/platforms.test.ts b/packages/cli/test/configurators/platforms.test.ts index e63d270..2601b8b 100644 --- a/packages/cli/test/configurators/platforms.test.ts +++ b/packages/cli/test/configurators/platforms.test.ts @@ -356,6 +356,24 @@ describe("configurePlatform", () => { expect(written).toContain("max_concurrent_threads_per_session = 3"); }); + it("configurePlatform('codex') adds managed settings to an unmarked user config", async () => { + const configPath = path.join(tmpDir, ".codex", "config.toml"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + 'model = "user-model"\n\n[project]\nname = "kept"\n', + ); + + await configurePlatform("codex", tmpDir); + + const written = fs.readFileSync(configPath, "utf-8"); + expect(written).toContain('model = "user-model"'); + expect(written).toContain('[project]\nname = "kept"'); + expect(written).toContain("# TRELLIS:CODEX_CONFIG:START"); + expect(written).toContain("# TRELLIS:CODEX_TABLES:START"); + expect(written).not.toContain('model = "gpt-5.6-sol"'); + }); + it("configurePlatform('codex') resolves PYTHON_CMD in hooks.json", async () => { await configurePlatform("codex", tmpDir); diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index 8ec4251..6b99aa1 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -299,7 +299,11 @@ describe("Agent Context Firewall dispatch CLI", () => { fs.writeFileSync(path.join(taskDir, "evidence.md"), "Evidence\n"); expect(create("job-project-coder", "coder").status).toBe(0); - expect(create("job-project-runner", "runner").status).toBe(0); + expect( + create("job-project-runner", "runner", [ + "--parent-job-id", "job-project-coder", + ]).status, + ).toBe(0); expect( create("job-project-review", "reviewer", [ "--profile", "evidence", @@ -361,6 +365,36 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(review.blind_review).toBe(true); }); + it("restricts non-coder writes to role-owned task record directories", () => { + const sourceWrite = create("job-review-source-write", "reviewer", [ + "--profile", "closure", + "--allowed-file", "src/**", + ]); + expect(sourceWrite.status).toBe(1); + expect(sourceWrite.stderr).toContain("role_write_scope"); + + const reviewRecord = create("job-review-record", "reviewer", [ + "--profile", "closure", + "--allowed-file", ".trellis/tasks/demo/hermes/reviews/**", + ]); + expect(reviewRecord.status, reviewRecord.stderr).toBe(0); + }); + + it("requires a runner checking coder work to bind the coder job", () => { + expect(create("job-parent-coder", "coder").status).toBe(0); + const unbound = create("job-unbound-runner", "runner", [ + "--profile", "validation", + ]); + expect(unbound.status).toBe(1); + expect(unbound.stderr).toContain("missing_parent_job"); + + const bound = create("job-bound-runner", "runner", [ + "--profile", "validation", + "--parent-job-id", "job-parent-coder", + ]); + expect(bound.status, bound.stderr).toBe(0); + }); + it("rejects missing jobs, stale revisions, and execution without the current package", () => { const missing = run(["validate", "--task", "demo", "--job-id", "missing"]); expect(missing.status).toBe(1); diff --git a/packages/cli/test/templates/codex-packaging.test.ts b/packages/cli/test/templates/codex-packaging.test.ts index 3cb0fbe..40598ab 100644 --- a/packages/cli/test/templates/codex-packaging.test.ts +++ b/packages/cli/test/templates/codex-packaging.test.ts @@ -46,5 +46,6 @@ describe("codex template packaging", () => { ); expect(copied).toContain("# TRELLIS:CODEX_CONFIG:START"); expect(copied).toContain("# TRELLIS:CODEX_MODEL_DEFAULTS:END"); + expect(copied).toContain("# TRELLIS:CODEX_TABLES:END"); }); }); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 34272f0..31c3439 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -2428,7 +2428,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { "trellis-code-architecture-review", "trellis-merge-review", "trellis-improve-codebase-architecture", - ])("treats %s as a non-main review gate subagent", (subagentType) => { + ])("keeps %s inside the reviewer write boundary", (subagentType) => { writeHermesWorkerRecords([ taskCard("job-review", "reviewer"), heartbeat("job-review"), @@ -2447,27 +2447,30 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); const decision = parseDecision(result); - expect(decision.permissionDecision).not.toBe("deny"); - expect(decision.additionalContext).toContain("Hermes Runtime"); - }); - - it("treats common subagent identity fields as non-main requests", () => { - for (const identity of [ - { subagent_type: "coder" }, - { subagentType: "builder" }, - { subagent: "runner" }, - { agent_role: "hermes-coder" }, - { agent_role: "hermes-scientist" }, - { agent_role: "scientist" }, - { agent_role: "claim-reviewer" }, - { agent_role: "research/scout" }, - { subagent_type: "trellis-implement" }, - { subagent_type: "trellis-spec-review" }, - { subagent_type: "trellis-code-architecture-review" }, - ]) { + expect(decision.permissionDecision).toBe("deny"); + expect(decision.permissionDecisionReason).toContain( + "reviewer cannot write implementation files", + ); + }); + + it("binds common subagent identity fields to their task-card role", () => { + const identities: [Record, string][] = [ + [{ subagent_type: "coder" }, "coder"], + [{ subagentType: "builder" }, "coder"], + [{ subagent: "runner" }, "runner"], + [{ agent_role: "hermes-coder" }, "coder"], + [{ agent_role: "hermes-scientist" }, "planner"], + [{ agent_role: "scientist" }, "planner"], + [{ agent_role: "claim-reviewer" }, "reviewer"], + [{ agent_role: "research/scout" }, "researcher"], + [{ subagent_type: "trellis-implement" }, "coder"], + [{ subagent_type: "trellis-spec-review" }, "reviewer"], + [{ subagent_type: "trellis-code-architecture-review" }, "reviewer"], + ]; + for (const [identity, role] of identities) { writeHermesWorkerRecords([ - taskCard("job-coder", "coder"), - heartbeat("job-coder"), + taskCard("job-role", role), + heartbeat("job-role"), ]); const result = runHermesRuntimeGuard(repoRoot, { @@ -2483,11 +2486,38 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { expect(result.status).toBe(0); const decision = parseDecision(result); - expect(decision.permissionDecision).not.toBe("deny"); - expect(decision.additionalContext).toContain("Hermes Runtime"); + if (role === "coder") { + expect(decision.permissionDecision).not.toBe("deny"); + expect(decision.additionalContext).toContain("Hermes Runtime"); + } else { + expect(decision.permissionDecision).toBe("deny"); + expect(decision.permissionDecisionReason).not.toContain( + "main agent firewall", + ); + } } }); + it("allows reviewer records only inside the task review directory", () => { + writeHermesWorkerRecords([ + taskCard("job-review", "reviewer", { + allowed_files: [`.trellis/tasks/${taskName}/hermes/reviews/**`], + }), + heartbeat("job-review"), + ]); + const result = runHermesRuntimeGuard(repoRoot, { + cwd: repoRoot, + hook_event_name: "PreToolUse", + agent_role: "reviewer", + tool_name: "Write", + tool_input: { + file_path: `.trellis/tasks/${taskName}/hermes/reviews/quality.md`, + }, + session_id: "test-session", + }); + expect(parseDecision(result).permissionDecision).not.toBe("deny"); + }); + it("denies main-agent writes without an active task while keeping read-only git allowed", () => { writeIdleSession(); diff --git a/packages/cli/test/utils/codex-config.test.ts b/packages/cli/test/utils/codex-config.test.ts index 4260886..f08d25d 100644 --- a/packages/cli/test/utils/codex-config.test.ts +++ b/packages/cli/test/utils/codex-config.test.ts @@ -5,6 +5,8 @@ import { CODEX_CONFIG_BLOCK_START, CODEX_MODEL_BLOCK_END, CODEX_MODEL_BLOCK_START, + CODEX_TABLES_BLOCK_END, + CODEX_TABLES_BLOCK_START, isTrellisCodexConfigMerge, mergeTrellisCodexConfig, stripTrellisCodexConfig, @@ -17,7 +19,9 @@ function withoutMarkers(content: string): string { .replace(`${CODEX_CONFIG_BLOCK_START}\n`, "") .replace(`\n${CODEX_CONFIG_BLOCK_END}`, "") .replace(`${CODEX_MODEL_BLOCK_START}\n`, "") - .replace(`\n${CODEX_MODEL_BLOCK_END}`, ""); + .replace(`\n${CODEX_MODEL_BLOCK_END}`, "") + .replace(`${CODEX_TABLES_BLOCK_START}\n`, "") + .replace(`\n${CODEX_TABLES_BLOCK_END}`, ""); } describe("mergeTrellisCodexConfig", () => { @@ -63,7 +67,7 @@ keep = true expect(isTrellisCodexConfigMerge(legacy, migrated.content)).toBe(true); }); - it("leaves an unmarked user model untouched", () => { + it("installs managed settings around an unmarked user config without replacing its model", () => { const existing = `model = "my-project-model" model_reasoning_effort = "low" @@ -71,11 +75,16 @@ model_reasoning_effort = "low" name = "example" `; - expect(mergeTrellisCodexConfig(existing, TEMPLATE)).toEqual({ - content: existing, - changed: false, - migratedLegacyDefaults: false, - }); + const merged = mergeTrellisCodexConfig(existing, TEMPLATE); + expect(merged.changed).toBe(true); + expect(merged.migratedLegacyDefaults).toBe(false); + expect(merged.content).toContain('model = "my-project-model"'); + expect(merged.content).toContain('model_reasoning_effort = "low"'); + expect(merged.content).not.toContain('model = "gpt-5.6-sol"'); + expect(merged.content).toContain(CODEX_CONFIG_BLOCK_START); + expect(merged.content).toContain(CODEX_TABLES_BLOCK_START); + expect(merged.content).toContain("[project]\nname = \"example\""); + expect(isTrellisCodexConfigMerge(existing, merged.content)).toBe(true); }); it("does not alter malformed marker blocks", () => { @@ -85,6 +94,18 @@ model = "my-project-model" expect(mergeTrellisCodexConfig(existing, TEMPLATE).content).toBe(existing); }); + + it("does not duplicate a user-owned Codex table when adding managed settings", () => { + const existing = `[features.multi_agent_v2] +enabled = false +max_concurrent_threads_per_session = 1 +`; + const merged = mergeTrellisCodexConfig(existing, TEMPLATE).content; + + expect(merged.match(/\[features\.multi_agent_v2\]/g)).toHaveLength(1); + expect(merged).toContain("enabled = false"); + expect(merged).toContain("[hermes.context_firewall]"); + }); }); describe("stripTrellisCodexConfig", () => { From 9a54fc5dd82dea7c58e3a0e7f7e5ee8429982edc Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Thu, 23 Jul 2026 10:34:24 +0800 Subject: [PATCH 08/13] chore(release): prepare 0.7.1-beta.1 --- README.md | 4 ++-- docs/hermes_role_profile_migration.md | 2 +- docs/manual/01-overview.md | 4 ++-- docs/manual/02-installation.md | 12 ++++++------ docs/manual/03-first-research-task.md | 2 +- docs/manual/04-how-it-works.md | 2 +- docs/manual/05-project-setup.md | 2 +- docs/manual/06-tasks-and-planning.md | 2 +- docs/manual/07-spec-and-context.md | 2 +- docs/manual/08-hermes-records.md | 2 +- docs/manual/09-experiments.md | 2 +- docs/manual/10-runs-and-replay.md | 2 +- docs/manual/11-metrics-and-comparison.md | 2 +- docs/manual/12-reports-and-claims.md | 2 +- docs/manual/13-workers-and-gates.md | 2 +- docs/manual/14-workflows-and-platforms.md | 4 ++-- docs/manual/15-channel-and-memory.md | 2 +- docs/manual/16-update-recovery-and-uninstall.md | 2 +- docs/manual/17-lean-research-closure.md | 2 +- docs/manual/README.md | 2 +- docs/manual/appendix-a-cli-reference.md | 4 ++-- docs/manual/appendix-b-directory-reference.md | 2 +- docs/manual/appendix-c-configuration-reference.md | 2 +- docs/manual/appendix-d-record-schema.md | 2 +- docs/manual/appendix-e-troubleshooting.md | 2 +- docs/manual/appendix-f-feature-status.md | 4 ++-- docs/manual/sources.md | 2 +- packages/cli/README.md | 4 ++-- packages/cli/package.json | 2 +- .../cli/src/templates/trellis/hermes/config.yaml | 2 +- packages/core/README.md | 4 ++-- packages/core/package.json | 2 +- 32 files changed, 44 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 51104a3..1e53fef 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.1-beta.0 +npm install -g research-trellis@0.7.1-beta.1 research-trellis --version ``` @@ -107,7 +107,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.0 +npm install -g research-trellis@0.7.1-beta.1 research-trellis --version ``` diff --git a/docs/hermes_role_profile_migration.md b/docs/hermes_role_profile_migration.md index 7d35b42..dc3fa7e 100644 --- a/docs/hermes_role_profile_migration.md +++ b/docs/hermes_role_profile_migration.md @@ -26,7 +26,7 @@ ## 派发上下文 -`v0.7.1-beta.0`(当前测试版)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: +从 `v0.7.1-beta.0`(首个测试版)开始,主代理先生成 validated dispatch(已验证派发),其中只保留: 1. `job_id`(工作编号)和 `hermes_revision`(Hermes 修订号); 2. 当前角色与模式; diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index 95cef6d..2d075b3 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,7 +11,7 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.7.1-beta.0`(测试版)。 +- 接受当前版本为 `0.7.1-beta.1`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 @@ -45,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 4be1cbd..07210f7 100644 --- a/docs/manual/02-installation.md +++ b/docs/manual/02-installation.md @@ -28,7 +28,7 @@ git --version 新包发布后,推荐固定安装本手册对应的测试版: ```bash -npm install --global research-trellis@0.7.1-beta.0 +npm install --global research-trellis@0.7.1-beta.1 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.7.1-beta.0 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.1-beta.1 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.7.1-beta.0 +npm install --global research-trellis@0.7.1-beta.1 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.7.1-beta.0`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.1-beta.1`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.7.1-beta.0`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.1-beta.1`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index 96d4d69..a12948e 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 5112f83..b427ed7 100644 --- a/docs/manual/04-how-it-works.md +++ b/docs/manual/04-how-it-works.md @@ -62,7 +62,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 e60b741..8ec97d2 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 c26a9ff..b35f914 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -104,7 +104,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 275badd..be8aede 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -62,7 +62,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 b2e08b2..835c282 100644 --- a/docs/manual/08-hermes-records.md +++ b/docs/manual/08-hermes-records.md @@ -72,7 +72,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind evidence ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 75b356e..239b7dd 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -78,7 +78,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 - 结果:命令允许列表和本地运行记录均可定位。 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index fb60377..47c0a38 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/runner.py replay --task "$TASK" --run-id "$RUN ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 9a8927a..ba241cb 100644 --- a/docs/manual/11-metrics-and-comparison.md +++ b/docs/manual/11-metrics-and-comparison.md @@ -77,7 +77,7 @@ python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 61aeeef..f8a9529 100644 --- a/docs/manual/12-reports-and-claims.md +++ b/docs/manual/12-reports-and-claims.md @@ -69,7 +69,7 @@ python3 ./.trellis/scripts/hermes/report.py approval-gate --task "$TASK" --claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 02fb0fb..5834bdd 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -179,7 +179,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index 16d3da6..e75ff6f 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -71,7 +71,7 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 -`v0.7.1-beta.0`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`v0.7.1-beta.1`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 @@ -124,7 +124,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index 5840d3b..ee450f2 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.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 fd23c10..bdce79a 100644 --- a/docs/manual/16-update-recovery-and-uninstall.md +++ b/docs/manual/16-update-recovery-and-uninstall.md @@ -69,7 +69,7 @@ research-trellis uninstall --yes ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 更名前基准提交:`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 fc5efc0..0176b42 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -186,7 +186,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.0`(测试版)。 +- 版本:`0.7.1-beta.1`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index f58667e..50083ea 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.0`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.1`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 85abe23..098d804 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1-beta.0`(测试版)。 +适用于 `0.7.1-beta.1`(测试版)。 ## 前置条件 @@ -115,7 +115,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id Date: Fri, 24 Jul 2026 23:33:47 +0800 Subject: [PATCH 09/13] feat(hermes): add lifecycle plan anchors --- README.md | 1 + docs/manual/06-tasks-and-planning.md | 2 +- docs/manual/17-lean-research-closure.md | 34 +- .../appendix-c-configuration-reference.md | 6 +- docs/manual/appendix-d-record-schema.md | 2 + docs/manual/appendix-f-feature-status.md | 1 + marketplace/workflows/native/workflow.md | 4 +- packages/cli/README.md | 3 +- packages/cli/scripts/hermes-preflight.js | 1 + .../shared-hooks/hermes-runtime-guard.py | 37 + .../shared-hooks/inject-workflow-state.py | 14 + .../cli/src/templates/trellis/config.yaml | 7 + packages/cli/src/templates/trellis/index.ts | 2 + .../src/templates/trellis/scripts/closure.py | 16 +- .../trellis/scripts/common/closure.py | 787 ++++++++++++++++-- .../trellis/scripts/common/config.py | 36 + .../trellis/scripts/common/dispatch.py | 37 + .../trellis/scripts/common/lifecycle.py | 269 ++++++ .../trellis/scripts/common/task_store.py | 35 +- .../trellis/scripts/hermes/dispatch.py | 11 +- .../cli/src/templates/trellis/scripts/task.py | 26 +- .../cli/src/templates/trellis/workflow.md | 4 +- ...t-uninstall-overdelete.integration.test.ts | 9 + packages/cli/test/regression.test.ts | 2 +- ...agent-context-firewall.integration.test.ts | 82 ++ .../test/scripts/closure.integration.test.ts | 393 ++++++++- .../scripts/lifecycle.integration.test.ts | 244 ++++++ .../cli/test/templates/shared-hooks.test.ts | 29 +- 28 files changed, 2014 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/templates/trellis/scripts/common/lifecycle.py create mode 100644 packages/cli/test/scripts/lifecycle.integration.test.ts diff --git a/README.md b/README.md index 1e53fef..71ac9f6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - 项目规范和工作流规则放进 `.trellis/spec/` 与 `.trellis/workflow.md`; - 子代理执行记录、实验配置、指标结构和报告模板放进 `.trellis/hermes/` 相关目录; - 关键门禁采用“缺记录就失败”的方式,减少 AI 在流程里跳步。 +- 内置任务生命周期协调默认启用,在创建、启动、子代理返回、结束、关闭和归档边界修复可确定的状态偏差,并记录修订历史。 它的目标不是让 AI 更快地直接写代码,而是让科研项目里的 AI 协作更可追踪、可复盘、可审查。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index b35f914..d90a30e 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ --decision-ref design.md ``` -引用必须位于仓库或当前任务内。记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 +引用必须位于仓库或当前任务内。讨论记录至少包含 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。已经校验计划的 `amend`(计划变更)还必须补充 `Original Commitment`(原承诺)、`Failure Evidence`(失败证据)和 `Failed Attempts`(已尝试路径),避免把新方案写成原方案自然完成。事件和 Task Capsule(任务胶囊)只显示引用,不加载正文。旧任务已有 `grill_completed=true`(已完成讨论)但没有引用时继续运行并给出警告;研究协议再次修改后必须重新记录。普通 delivery(确定性交付)和 execution(冻结协议执行)没有这一门禁。 ## 预期结果 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 0176b42..4b4ee49 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -51,6 +51,34 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit `task.json`(任务状态)是当前状态的唯一事实源。每次 Closure 语义写入都会原子替换文件并递增 `hermes_revision`(Hermes 修订号)。阶段和工作包状态只能通过命令变更;对话里说“已经完成”不会把任务改成 `completed`(已完成)。 +## 默认生命周期协调 + +内置任务生命周期协调默认启用。任务创建、启动、Closure 命令、每轮任务续接、子代理返回、会话结束检查和归档都会读取同一份 `task.json`,并只修复能够从现有状态确定的偏差,例如: + +- 原版任务状态与 Hermes 阶段映射不一致; +- 当前工作包引用不存在,或遗漏了唯一处于运行、审核、阻塞状态的工作包; +- 派发失败已阻塞工作包,但任务阶段尚未同步; +- 下一动作为空,或因结构修复需要重新计算; +- 关闭标记只写入了一半。 + +协调器重复运行不会重复写事件或递增修订号。发生实际修复时,才写入 `lifecycle_reconciled`(生命周期已校正)事件。归档成功后写入 `task_archived`(任务已归档)事件。 + +生命周期协调不会接受证据、处置工作包、批准主张或关闭任务。`runner`(运行代理)成功仍只代表运行结束;工作包必须经过 `package-check`(工作包检查)和 `package-done`(工作包处置),任务必须经过 `audit`(审计)和 `close`(关闭)。这样可以修复“模型忘记同步状态”,但不会把不完整结果误判为完成。 + +需要诊断时可以在 `.trellis/config.yaml` 中设置 `task_lifecycle.enabled: false`,或临时设置 `TRELLIS_TASK_LIFECYCLE=0`(关闭内置任务生命周期协调)。自定义 Shell 生命周期钩子仍默认留空。 + +## 计划锚点与方案漂移 + +每次 `validate`(校验)成功都会在 `task.json`(任务状态)生成 `plan_anchor`(计划锚点)。它固定当时的 intent(目标)、范围、完成定义、工作包结果与验收、证据要求、研究路线、关键约束,以及 `prd.md`(需求文档)、`design.md`(设计方案)、`implement.md`(实施计划)和当前决策记录的文件哈希。完整锚点还随 `plan_validated`(计划已校验)事件追加保存,因此重新校验不会抹掉旧方案。它不是第二份当前状态;当前状态仍以 `task.json` 为准,锚点只是“哪一版方案得到批准”的基线。 + +已有的 Hermes 任务若已在 ready、running、review 或 blocked 阶段但没有锚点,首次生命周期续接会生成一次 `legacy_migration`(旧任务迁移)锚点并记录事件;原版非 Hermes 任务不受影响。 + +在 ready、running 或 review 阶段,如果这些内容被直接改动,生命周期协调会写入 `plan_drift_detected`(计划漂移已发现)事件并进入 `blocked`(阻塞)。`package-check`(工作包检查)、`package-done`(工作包处置)、正式 agent 派发、`audit`(审计)、`close`(关闭)都会拒绝继续。这样“当前报错所以换一个方案”的判断不会悄悄替换原计划。 + +遇到困难时先在原计划内复现和定位;有限 repair(修复)仍只处理审计缺口。需要改变目标、完成定义、工作包 outcome(结果)、`done_when`(完成条件)、证据要求、范围、研究协议或方案文档时,使用 `amend`(计划变更)。方案文件先修改,再用 `amend --field plan_documents --value current`(接受当前方案文件)确认这一版。已经校验的验收、研究协议和方案文件变化必须同时提供人工批准与 `decision_ref`(决策记录引用);记录必须有 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Original Commitment`(原承诺)、`Failure Evidence`(失败证据)、`Failed Attempts`(已尝试路径)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。完成变更后任务回到 planning,再次 `validate` 才会生成新锚点。 + +模型架构、新假设、数据、切分、预处理、指标、基线或主张范围变化属于探索性研究;其中模型架构变化必须创建独立探索任务,不会覆盖旧任务结果。旧任务应明确保留为完成、延期、阻塞或失败,而不是把新路线写成原方案已经完成。 + ## Task Capsule `capsule`(紧凑上下文)默认控制在约 500–1000 个字符,包含任务目标、范围、模式、阶段、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。 @@ -132,13 +160,15 @@ python3 ./.trellis/scripts/closure.py validate --task "$TASK" ## 计划变更 -低风险变更直接更新 `task.json` 并记录 `plan_amended`(计划已修改)事件: +尚未 `validate`(校验)的 planning(规划)任务可以记录低风险变更;已经校验的验收变化必须使用人工批准和决策记录: ```bash python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ --field work_packages.WP2.done_when \ --value '["固定测试通过"]' \ - --reason "验收条件需要可执行" + --reason "验收条件需要可执行" \ + --approved-by 'human/root' \ + --decision-ref design.md ``` 高风险研究契约变更必须显式记录人工批准: diff --git a/docs/manual/appendix-c-configuration-reference.md b/docs/manual/appendix-c-configuration-reference.md index fe71a21..7fe17c5 100644 --- a/docs/manual/appendix-c-configuration-reference.md +++ b/docs/manual/appendix-c-configuration-reference.md @@ -21,7 +21,8 @@ | `session_commit_message`(会话提交说明) | 日志自动提交说明 | | `max_journal_lines`(日志最大行数) | 工作日志轮转阈值,默认 2000 | | `session_auto_commit`(会话自动提交) | 设为 `false`(关闭)可避免脚本自动提交 | -| `hooks`(生命周期钩子) | 任务创建、启动、结束和归档后的命令 | +| `task_lifecycle.enabled`(内置任务生命周期) | 默认 `true`(启用);幂等修复可推导状态,不自动完成工作包或任务 | +| `hooks`(自定义生命周期钩子) | 任务创建、启动、结束和归档后的可选 Shell 命令;默认留空 | | `packages`(包列表) | 多包项目路径和类型 | | `codex.dispatch_mode`(Codex 调度模式) | 旧 Trellis 实现/检查代理的路由设置;不会把 Hermes 原生派发升级为强门禁 | @@ -33,6 +34,7 @@ | --- | --- | | `TRELLIS_HOOKS=0`(关闭钩子) | 明确关闭钩子;派发和关闭时提示当前处于协议约束模式 | | `TRELLIS_DISABLE_HOOKS=1`(禁用钩子) | 与上一项相同,用于兼容现有启动方式 | +| `TRELLIS_TASK_LIFECYCLE=0`(关闭任务生命周期) | 仅关闭内置状态协调,主要用于诊断;不改变显式 Closure 命令的门禁 | | `TRELLIS_HOOKS_ACTIVE=1`(钩子活动标记) | 仅作状态提示;必须同时存在签名且新鲜的心跳,不能单独通过门禁 | | `TRELLIS_PLATFORM`(平台) | 仅接受 `claude`(Claude)或 `codex`(Codex)作为正式防火墙平台 | | `TRELLIS_CONTEXT_FIREWALL_HEARTBEAT_TTL`(心跳有效期) | 新鲜心跳的秒数,默认 900 | @@ -53,7 +55,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 失败恢复 -生命周期钩子失败只会警告,不阻止主操作;实验配置失败会阻止任务启动和运行,应先修正。 +自定义生命周期钩子失败只会警告,不阻止主操作;内置协调器写入失败会保留原状态并要求修复。实验配置失败会阻止任务启动和运行,应先修正。 ## 验证记录 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index d21cbda..38b1727 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -28,6 +28,8 @@ | `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` | +`hermes/task-events.jsonl`(任务事件记录)还包含 `phase_changed`(阶段变化)、`plan_validated`(计划已校验)、`plan_anchor_initialized`(计划锚点已初始化)、`plan_drift_detected`(计划漂移已发现)、`package_started`(工作包启动)、`package_completed`(工作包完成)、`package_blocked`(工作包阻塞)、`lifecycle_reconciled`(生命周期校正)、`task_closed`(任务关闭)和 `task_archived`(任务归档)。`plan_validated`(计划已校验)保存当次完整计划锚点,包括方案字段和方案文件哈希;后续重新校验会追加新事件,不覆盖旧方案。生命周期校正事件额外记录实际变化字段和发现的不一致;没有状态变化时不会追加事件。 + 正式角色还有两种 JSON 文件,不是 `JSONL`(逐行 JSON): | 文件 | 关键字段 | diff --git a/docs/manual/appendix-f-feature-status.md b/docs/manual/appendix-f-feature-status.md index e101f85..ed78033 100644 --- a/docs/manual/appendix-f-feature-status.md +++ b/docs/manual/appendix-f-feature-status.md @@ -20,6 +20,7 @@ | 项目初始化闭环 | 已实现、实验性 | 区分空仓库与已有项目;事实索引只提供读取路径,不自动提取或确认项目事实 | | 任务、规范、上下文清单 | 已实现 | 规划质量仍需人工审阅 | | Lean Research Closure | 已实现、实验性 | 默认 1–4 个工作包;自动候选仍需人工审阅 | +| 默认任务生命周期协调 | 已实现、实验性 | 幂等修复派生状态并记录事件;不接受证据、不完成工作包、不批准主张、不自动关闭任务 | | Task Capsule 与条件式 Handoff | 已实现 | 平台没有主会话 hook 时需手动调用 | | 五角色与 Profile | 已实现、实验性 | 正式平台代理模板当前只提供 Claude Code 和 Codex;旧任务卡可兼容读取 | | Agent Context Firewall | 已实现、实验性 | Claude 支持派发替换与结果净化;Codex 支持写入、命令和结束门禁,子代理输入仍依赖协议 | diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 959718d..1a1c201 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -81,7 +81,7 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] > 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 +**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。默认启用的内置生命周期协调器会在任务命令、每轮续接和代理返回边界幂等修复可推导状态;它不接受证据、不处置工作包、不批准主张,也不关闭任务。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 ### Lean Research Closure @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案文档哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案文档或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/README.md b/packages/cli/README.md index e60c4f5..71ac9f6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,6 +30,7 @@ - 项目规范和工作流规则放进 `.trellis/spec/` 与 `.trellis/workflow.md`; - 子代理执行记录、实验配置、指标结构和报告模板放进 `.trellis/hermes/` 相关目录; - 关键门禁采用“缺记录就失败”的方式,减少 AI 在流程里跳步。 +- 内置任务生命周期协调默认启用,在创建、启动、子代理返回、结束、关闭和归档边界修复可确定的状态偏差,并记录修订历史。 它的目标不是让 AI 更快地直接写代码,而是让科研项目里的 AI 协作更可追踪、可复盘、可审查。 @@ -203,7 +204,7 @@ node /path/to/research-trellis/packages/cli/bin/trellis.js update ### 7. Agent Context Firewall -每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex native`(Codex 原生)仅为建议性,`Codex strict`(Codex 严格模式)使用结构化输出参数执行。 +每次正式角色派发先生成绑定 `hermes_revision`(Hermes 修订号)的结构化文件,正文最多 2000 字符、引用最多 3 个。`Claude Code`(Claude 代码工具)钩子只接收 `job_id`(工作编号)并替换长提示;`Codex`(代码代理平台)使用同一份紧凑派发和结果契约,在当前项目工作区协作。 返回必须包含 `uncertainties`(不确定项),长日志、完整差异和搜索过程不会进入主上下文。原始跟踪保存在 `.trellis/.runtime/`(本地运行目录),成功或失败都会机械更新下一动作,但不会仅凭聊天关闭任务。 diff --git a/packages/cli/scripts/hermes-preflight.js b/packages/cli/scripts/hermes-preflight.js index b1feb23..fd7fb45 100644 --- a/packages/cli/scripts/hermes-preflight.js +++ b/packages/cli/scripts/hermes-preflight.js @@ -34,6 +34,7 @@ const REQUIRED_FILES = [ "packages/cli/src/templates/trellis/hermes/roles/reviewer.md", "packages/cli/src/templates/trellis/scripts/closure.py", "packages/cli/src/templates/trellis/scripts/common/closure.py", + "packages/cli/src/templates/trellis/scripts/common/lifecycle.py", "packages/cli/src/templates/trellis/scripts/common/roles.py", "packages/cli/src/templates/trellis/scripts/common/dispatch.py", "packages/cli/src/templates/trellis/scripts/common/firewall.py", diff --git a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py index 30acf61..edb80bc 100644 --- a/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py +++ b/packages/cli/src/templates/shared-hooks/hermes-runtime-guard.py @@ -1363,6 +1363,34 @@ def is_hermes_closure_task(task_data: dict[str, Any]) -> bool: ) +def reconcile_active_task_data( + root: Path, + task: str | None, + task_data: dict[str, Any], + event_name: str, + platform: str | None, +) -> dict[str, Any]: + """Apply safe lifecycle repairs without making hook availability brittle.""" + if not task or not is_hermes_closure_task(task_data): + return task_data + scripts_dir = root / ".trellis" / "scripts" + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: + from common.lifecycle import reconcile_task_lifecycle # type: ignore[import-not-found] + reconcile_task_lifecycle( + root / ".trellis" / "tasks" / task, + task_data, + root=root, + actor=f"hook/{platform or 'unknown'}", + reason=f"{event_name or 'hook'} lifecycle check", + initialize_legacy_anchor=False, + ) + except Exception: + pass + return task_data + + def record_hook_heartbeat( root: Path, platform: str | None, @@ -1546,6 +1574,14 @@ def handle_agent_result_event( if state.get("status") not in {"blocked", "failed", "stale"}: return block_payload(event_name, "Hermes Context Firewall: final Agent result is missing.") + reconcile_active_task_data( + root, + task, + task_data, + event_name, + detect_platform(data), + ) + if event_name == "PostToolUse": tool_response = data.get("tool_response") if isinstance(tool_response, dict) and tool_response.get("status") == "async_launched": @@ -1641,6 +1677,7 @@ def main() -> int: runtime_root = root / ".trellis" / "scripts" / "hermes" task = resolve_active_task(root, data) task_data = read_active_task_data(root, task) + task_data = reconcile_active_task_data(root, task, task_data, event_name, platform) record_hook_heartbeat( root, platform, diff --git a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py index 13dfa10..bb50533 100644 --- a/packages/cli/src/templates/shared-hooks/inject-workflow-state.py +++ b/packages/cli/src/templates/shared-hooks/inject-workflow-state.py @@ -325,10 +325,24 @@ def _closure_turn_context(root: Path, input_data: dict) -> tuple[str, str | None research_route_summary, skill_route_guidance, ) + from common.lifecycle import reconcile_task_lifecycle # type: ignore[import-not-found] except Exception: return None if not isinstance(data, dict) or not is_closure_task(data): return None + try: + reconcile_task_lifecycle( + task_dir, + data, + root=root, + actor=f"hook/{_detect_platform(input_data) or 'unknown'}", + reason="user prompt lifecycle check", + initialize_legacy_anchor=False, + ) + except Exception: + # Per-turn context injection must remain available even when a damaged + # task requires explicit repair through the closure CLI. + pass task_id = str(data.get("id") or task_dir.name) revision = data.get("hermes_revision", 0) phase = str(data.get("hermes_phase") or "planning") diff --git a/packages/cli/src/templates/trellis/config.yaml b/packages/cli/src/templates/trellis/config.yaml index f27077c..db27252 100644 --- a/packages/cli/src/templates/trellis/config.yaml +++ b/packages/cli/src/templates/trellis/config.yaml @@ -36,6 +36,13 @@ max_journal_lines: 2000 # Task Lifecycle Hooks #------------------------------------------------------------------------------- +# Deterministic Hermes task-state reconciliation is enabled by default. +# It repairs only derived state and never accepts evidence, completes a work +# package, approves a claim, or closes a task. Set enabled to false only when +# diagnosing lifecycle behavior; custom shell hooks below remain opt-in. +task_lifecycle: + enabled: true + # Shell commands to run after task lifecycle events. # Each hook receives TASK_JSON_PATH environment variable pointing to task.json. # Hook failures print a warning but do not block the main operation. diff --git a/packages/cli/src/templates/trellis/index.ts b/packages/cli/src/templates/trellis/index.ts index 7fa11ee..f7d760b 100644 --- a/packages/cli/src/templates/trellis/index.ts +++ b/packages/cli/src/templates/trellis/index.ts @@ -81,6 +81,7 @@ export const commonTasks = readTemplate("scripts/common/tasks.py"); export const commonTaskContext = readTemplate("scripts/common/task_context.py"); export const commonTaskStore = readTemplate("scripts/common/task_store.py"); export const commonClosure = readTemplate("scripts/common/closure.py"); +export const commonLifecycle = readTemplate("scripts/common/lifecycle.py"); export const commonRoles = readTemplate("scripts/common/roles.py"); export const commonDispatch = readTemplate("scripts/common/dispatch.py"); export const commonFirewall = readTemplate("scripts/common/firewall.py"); @@ -153,6 +154,7 @@ export function getAllScripts(): Map { scripts.set("common/task_context.py", commonTaskContext); scripts.set("common/task_store.py", commonTaskStore); scripts.set("common/closure.py", commonClosure); + scripts.set("common/lifecycle.py", commonLifecycle); scripts.set("common/roles.py", commonRoles); scripts.set("common/dispatch.py", commonDispatch); scripts.set("common/firewall.py", commonFirewall); diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index 1c86aa4..7ec5e53 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -33,6 +33,7 @@ validate_and_ready, write_handoff, ) +from common.lifecycle import reconcile_task_lifecycle def add_common(parser: argparse.ArgumentParser) -> None: @@ -124,7 +125,11 @@ def parser() -> argparse.ArgumentParser: amend.add_argument("--value", required=True, help="JSON value or plain string") amend.add_argument("--reason", required=True) amend.add_argument("--affected-package", action="append", default=[]) - amend.add_argument("--approved-by", help="Required for high-risk research contract changes") + amend.add_argument("--approved-by", help="Required for high-risk or validated acceptance changes") + amend.add_argument( + "--decision-ref", + help="Required for approved post-validation acceptance or research-contract changes", + ) audit = subparsers.add_parser("audit", help="Audit closure without making a new plan") add_common(audit) @@ -155,6 +160,14 @@ def main(argv: list[str] | None = None) -> int: print("Compatibility mode: legacy non-Hermes task; run closure.py plan to opt in.") return 0 raise ClosureError("legacy non-Hermes task; run closure.py plan before closure commands") + if is_closure_task(data) and args.command != "plan": + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=actor, + reason=f"closure.py {args.command}", + ) if args.command == "plan": warnings = plan_closure( task_dir, @@ -267,6 +280,7 @@ def main(argv: list[str] | None = None) -> int: actor=actor, affected_packages=args.affected_package, approved_by=args.approved_by, + decision_ref=args.decision_ref, ) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index bcf480e..9f93762 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -49,6 +49,19 @@ "evidence": "Evidence", "alternatives": "Alternatives", "failure conditions": "Failure Conditions", + "original commitment": "Original Commitment", + "failure evidence": "Failure Evidence", + "failed attempts": "Failed Attempts", +} +PLAN_CHANGE_DECISION_SECTION_HEADINGS = { + "decision": "Decision", + "rationale": "Rationale", + "evidence": "Evidence", + "original commitment": "Original Commitment", + "failure evidence": "Failure Evidence", + "failed attempts": "Failed Attempts", + "alternatives": "Alternatives", + "failure conditions": "Failure Conditions", } PACKAGE_STATUSES = { "pending", @@ -61,6 +74,11 @@ "waived", } DISPOSED_PACKAGE_STATUSES = {"done", "deferred", "waived"} +PLAN_ANCHOR_SCHEMA = "hermes-plan-anchor/v2" +LEGACY_PLAN_ANCHOR_SCHEMAS = {"hermes-plan-anchor/v1"} +PLAN_DOCUMENT_NAMES = ("prd.md", "design.md", "implement.md") +PLAN_DRIFT_BLOCKER_PREFIX = "plan anchor drift:" +PENDING_PLAN_PROJECTION_FIELD = "pending_plan_projection" HIGH_RISK_FIELDS = { "closure_mode", "hypothesis", @@ -77,6 +95,7 @@ "in_scope", "out_of_scope", "max_repair_count", + "plan_documents", } AMENDABLE_CLOSURE_FIELDS = { "closure_mode", @@ -95,6 +114,9 @@ "depends_on", } EVENT_TYPES = { + "plan_validated", + "plan_anchor_initialized", + "plan_drift_detected", "phase_changed", "package_started", "package_completed", @@ -104,6 +126,8 @@ "grill_completed", "repair_started", "task_closed", + "task_archived", + "lifecycle_reconciled", } MICROSTEP_RE = re.compile( r"^(?:read|open|inspect|view|modify (?:a )?file|edit (?:a )?file|" @@ -173,6 +197,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "research_change_fields": [], "grill_completed": False, "decision_ref": None, + "plan_change_ref": None, "constraints": { "excluded_platforms": [], "excluded_paths": [], @@ -185,6 +210,7 @@ def closure_defaults(mode: str = "lean") -> dict[str, Any]: "repair_count": 0, "max_repair_count": default_repair_limit(mode), "hermes_revision": 0, + "plan_anchor": None, "confirmed_dispatches": [], } @@ -310,6 +336,47 @@ def append_event( return record +def _save_task_with_events( + task_dir: Path, + data: dict[str, Any], + events: list[dict[str, Any]], +) -> None: + """Persist one semantic task update and its events, rolling back together.""" + with task_state_lock(task_dir): + original = read_json(task_dir / FILE_TASK_JSON) + if not isinstance(original, dict): + raise ClosureError("cannot read task.json before semantic update") + event_path = task_dir / "hermes" / "task-events.jsonl" + event_state = "file" if event_path.is_file() else "other" if event_path.exists() else "missing" + try: + event_before = event_path.read_text(encoding="utf-8") if event_state == "file" else "" + except OSError as exc: + raise ClosureError("failed to read task event log before semantic update") from exc + try: + save_task(task_dir, data, lock_held=True) + for event in events: + payload = dict(event) + event_type = str(payload.pop("event_type")) + append_event(task_dir, event_type, lock_held=True, **payload) + except Exception as exc: + task_restored = write_json_atomic(task_dir / FILE_TASK_JSON, original) + event_restored = True + if event_state == "file": + event_restored = write_text_atomic(event_path, event_before) + elif event_state == "missing" and event_path.is_file(): + try: + event_path.unlink() + except OSError: + event_restored = False + data.clear() + data.update(original) + if not task_restored or not event_restored: + raise ClosureError( + "semantic event failed and state rollback also failed; manual recovery required" + ) from exc + raise + + def actor_name(root: Path, explicit: str | None = None) -> str: return explicit or get_developer(root) or "unknown" @@ -365,6 +432,426 @@ def closure_constraints(data: dict[str, Any]) -> dict[str, Any]: } +def plan_anchor_status( + data: dict[str, Any], + task_dir: Path | None = None, +) -> dict[str, Any]: + """Compare the current task contract with its last validated baseline.""" + anchor = data.get("plan_anchor") + if anchor is None: + return {"status": "missing", "id": None, "hash": None, "differences": []} + if not isinstance(anchor, dict): + return { + "status": "invalid", + "id": None, + "hash": None, + "differences": ["plan_anchor"], + } + anchor_id = anchor.get("id") if isinstance(anchor.get("id"), str) else None + anchor_hash = anchor.get("hash") if isinstance(anchor.get("hash"), str) else None + projection = anchor.get("projection") + schema = anchor.get("schema") + if ( + not anchor_id + or not anchor_hash + or not isinstance(projection, dict) + or _plan_anchor_hash(projection) != anchor_hash + ): + return { + "status": "invalid", + "id": anchor_id, + "hash": anchor_hash, + "differences": ["plan_anchor"], + "upgrade_required": False, + } + incomplete_current_schema = schema == PLAN_ANCHOR_SCHEMA and ( + "research_contract" not in projection + or "max_repair_count" not in projection + ) + if schema in LEGACY_PLAN_ANCHOR_SCHEMAS or incomplete_current_schema: + current = plan_anchor_projection(data, task_dir) + if schema in LEGACY_PLAN_ANCHOR_SCHEMAS: + current.pop("plan_documents", None) + if "research_contract" not in projection and not current.get("research_contract"): + current.pop("research_contract", None) + if "max_repair_count" not in projection: + inferred_limit = default_repair_limit(str(projection.get("closure_mode") or "lean")) + if current.get("max_repair_count") == inferred_limit: + current.pop("max_repair_count", None) + differences = _plan_anchor_differences(projection, current) + return { + "status": "clean" if not differences else "drifted", + "id": anchor_id, + "hash": anchor_hash, + "differences": differences, + "upgrade_required": True, + } + if ( + schema != PLAN_ANCHOR_SCHEMA + ): + return { + "status": "invalid", + "id": anchor_id, + "hash": anchor_hash, + "differences": ["plan_anchor"], + } + current = plan_anchor_projection(data, task_dir) + if task_dir is None: + current["plan_documents"] = deepcopy(projection.get("plan_documents", [])) + differences = _plan_anchor_differences(projection, current) + return { + "status": "clean" if not differences else "drifted", + "id": anchor_id, + "hash": anchor_hash, + "differences": differences, + "upgrade_required": False, + } + + +def plan_anchor_projection( + data: dict[str, Any], + task_dir: Path | None = None, +) -> dict[str, Any]: + """Return the semantic task contract, excluding derived execution state.""" + packages: list[dict[str, Any]] = [] + for index, raw in enumerate(data.get("work_packages") or [], start=1): + if not isinstance(raw, dict): + continue + package = normalize_package(raw, index) + packages.append( + { + "id": package["id"], + "title": _normalize_contract_text(package["title"]), + "outcome": _normalize_contract_text(package["outcome"]), + "done_when": _normalized_contract_list(package["done_when"]), + "evidence_required": _normalized_contract_list(package["evidence_required"]), + "depends_on": _normalized_contract_list(package["depends_on"]), + } + ) + decision_ref = data.get("decision_ref") + plan_change_ref = data.get("plan_change_ref") + meta = data.get("meta") + research_contract = meta.get("research_contract") if isinstance(meta, dict) else None + return { + "intent": _normalize_contract_text(data.get("intent")), + "in_scope": _normalized_contract_list(data.get("in_scope")), + "out_of_scope": _normalized_contract_list(data.get("out_of_scope")), + "definition_of_done": _normalized_contract_list(data.get("definition_of_done")), + "closure_mode": str(data.get("closure_mode") or "lean"), + "max_repair_count": data.get("max_repair_count"), + "constraints": closure_constraints(data), + "research_contract": _normalize_contract_value(research_contract or {}), + "research_route": research_route(data), + "research_change_fields": _normalized_contract_list(_research_change_fields(data)), + "decision_ref": decision_ref.strip() if isinstance(decision_ref, str) and decision_ref.strip() else None, + "plan_change_ref": plan_change_ref.strip() if isinstance(plan_change_ref, str) and plan_change_ref.strip() else None, + "plan_documents": _plan_document_hashes(task_dir, decision_ref, plan_change_ref), + "work_packages": sorted(packages, key=lambda item: item["id"]), + } + + +def create_plan_anchor( + data: dict[str, Any], + task_dir: Path | None = None, + *, + origin: str = "validated", +) -> dict[str, Any]: + """Freeze the validated plan while keeping task.json as the live state.""" + projection = plan_anchor_projection(data, task_dir) + digest = _plan_anchor_hash(projection) + revision = data.get("hermes_revision", 0) + next_revision = revision + 1 if isinstance(revision, int) and not isinstance(revision, bool) else 0 + return { + "schema": PLAN_ANCHOR_SCHEMA, + "id": f"pa-{digest.removeprefix('sha256:')[:16]}", + "hash": digest, + "validated_at": now_utc(), + "origin": origin, + "source_revision": next_revision, + "projection": projection, + } + + +def _plan_anchor_hash(projection: dict[str, Any]) -> str: + serialized = json.dumps(projection, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _normalized_contract_list(value: Any) -> list[str]: + normalized = [_normalize_contract_text(item) for item in _string_list(value)] + return sorted(_dedupe([item for item in normalized if item])) + + +def _normalize_contract_text(value: Any) -> str: + return " ".join(str(value or "").split()) + + +def _normalize_contract_value(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): _normalize_contract_value(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, list): + return [_normalize_contract_value(item) for item in value] + if isinstance(value, str): + return _normalize_contract_text(value) + return deepcopy(value) + + +def _plan_document_hashes( + task_dir: Path | None, + *decision_refs: Any, +) -> list[dict[str, Any]]: + if task_dir is None: + return [] + task_root = task_dir.resolve() + refs: list[str] = list(PLAN_DOCUMENT_NAMES) + for decision_ref in decision_refs: + if isinstance(decision_ref, str) and decision_ref.strip(): + refs.append(decision_ref.strip()) + records: list[dict[str, Any]] = [] + root = get_repo_root(task_dir).resolve() + for ref in _dedupe(refs): + bases = [task_root, root] if not ref.startswith(".trellis/") else [root] + resolved: Path | None = None + for base in bases: + candidate = (base / ref).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file(): + resolved = candidate + break + if resolved is None: + if ref in PLAN_DOCUMENT_NAMES: + records.append({"path": ref, "status": "missing", "hash": None}) + continue + try: + content = resolved.read_text(encoding="utf-8") + try: + path = resolved.relative_to(task_root).as_posix() + except ValueError: + path = resolved.relative_to(root).as_posix() + except (OSError, UnicodeDecodeError, ValueError): + records.append({"path": ref, "status": "unreadable", "hash": None}) + continue + normalized = _normalize_plan_document(content) + records.append({ + "path": path, + "status": "present", + "hash": "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest(), + }) + return sorted(records, key=lambda item: str(item["path"])) + + +def _normalize_plan_document(content: str) -> str: + lines = [] + for raw in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw.rstrip() + line = re.sub(r"^(\s*[-*+]\s*)\[[ xX]\]", r"\1[ ]", line) + lines.append(line) + return "\n".join(lines).strip() + "\n" + + +def _plan_anchor_differences(expected: dict[str, Any], current: dict[str, Any]) -> list[str]: + differences: list[str] = [] + for field in ( + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "closure_mode", + "max_repair_count", + "research_route", + "research_change_fields", + "decision_ref", + "plan_change_ref", + "plan_documents", + ): + if expected.get(field) != current.get(field): + differences.append(field) + differences.extend( + _nested_contract_differences( + "constraints", + expected.get("constraints") or {}, + current.get("constraints") or {}, + ) + ) + differences.extend( + _nested_contract_differences( + "research_contract", + expected.get("research_contract") or {}, + current.get("research_contract") or {}, + ) + ) + expected_packages = { + str(item.get("id")): item + for item in expected.get("work_packages") or [] + if isinstance(item, dict) + } + current_packages = { + str(item.get("id")): item + for item in current.get("work_packages") or [] + if isinstance(item, dict) + } + if set(expected_packages) != set(current_packages): + differences.append("work_packages") + for package_id in sorted(set(expected_packages) & set(current_packages)): + for field in ("title", "outcome", "done_when", "evidence_required", "depends_on"): + if expected_packages[package_id].get(field) != current_packages[package_id].get(field): + differences.append(f"work_packages.{package_id}.{field}") + return _dedupe(differences) + + +def _nested_contract_differences(prefix: str, expected: Any, current: Any) -> list[str]: + if isinstance(expected, dict) and isinstance(current, dict): + differences: list[str] = [] + for key in sorted(set(expected) | set(current)): + child = f"{prefix}.{key}" + if key not in expected or key not in current: + differences.append(child) + else: + differences.extend( + _nested_contract_differences(child, expected[key], current[key]) + ) + return differences + return [] if expected == current else [prefix] + + +def _pending_plan_projection_errors( + task_dir: Path, + data: dict[str, Any], +) -> list[str]: + pending = data.get(PENDING_PLAN_PROJECTION_FIELD) + if pending is None: + return [] + if not isinstance(pending, dict): + return ["pending approved plan projection is invalid"] + differences = _plan_anchor_differences( + pending, + plan_anchor_projection(data, task_dir), + ) + if not differences: + return [] + return [ + "unapproved changes after plan amendment: " + ", ".join(differences[:3]) + ] + + +def _pending_grill_projection_errors( + task_dir: Path, + data: dict[str, Any], + decision_ref: str, +) -> list[str]: + pending = data.get(PENDING_PLAN_PROJECTION_FIELD) + if pending is None: + return [] + if not isinstance(pending, dict): + return ["pending approved plan projection is invalid"] + candidate = deepcopy(data) + candidate["decision_ref"] = decision_ref + current = plan_anchor_projection(candidate, task_dir) + differences = _plan_anchor_differences(pending, current) + unexpected = [ + item for item in differences if item not in {"decision_ref", "plan_documents"} + ] + if unexpected: + return [ + "unapproved changes before research decision: " + + ", ".join(unexpected[:3]) + ] + if "plan_documents" in differences: + expected_docs = { + str(item.get("path")): item + for item in pending.get("plan_documents") or [] + if isinstance(item, dict) + } + current_docs = { + str(item.get("path")): item + for item in current.get("plan_documents") or [] + if isinstance(item, dict) + } + changed_paths = { + path + for path in set(expected_docs) | set(current_docs) + if expected_docs.get(path) != current_docs.get(path) + } + allowed_path = _plan_reference_path(task_dir, decision_ref) + if allowed_path is None or changed_paths - {allowed_path}: + return [ + "unapproved plan document changes before research decision: " + + ", ".join(sorted(changed_paths)[:3]) + ] + return [] + + +def _set_pending_plan_projection(task_dir: Path, data: dict[str, Any]) -> None: + data[PENDING_PLAN_PROJECTION_FIELD] = plan_anchor_projection(data, task_dir) + + +def _plan_reference_path(task_dir: Path, reference: str) -> str | None: + root = get_repo_root(task_dir).resolve() + task_root = task_dir.resolve() + bases = [task_root, root] if not reference.startswith(".trellis/") else [root] + for base in bases: + candidate = (base / reference).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError: + continue + if not candidate.is_file(): + continue + try: + return candidate.relative_to(task_root).as_posix() + except ValueError: + try: + return candidate.relative_to(root).as_posix() + except ValueError: + return None + return None + + +def _allowed_existing_drift_for_amend(field: str) -> set[str]: + if field == "plan_documents": + return {"plan_documents"} + parts = field.split(".") + if parts[0] == "work_packages" and len(parts) == 3: + return {field} + if parts[0] == "constraints" and len(parts) == 2: + return {field} + if len(parts) == 1 and field in { + "intent", + "in_scope", + "out_of_scope", + "definition_of_done", + "closure_mode", + "max_repair_count", + }: + return {field} + research_parts = _research_field_parts(field) + return {"research_contract." + ".".join(research_parts)} if research_parts else set() + + +def _require_clean_plan_anchor(task_dir: Path, data: dict[str, Any]) -> None: + status = plan_anchor_status(data, task_dir) + if status["status"] == "clean": + return + if status["status"] == "missing": + raise ClosureError("validated plan anchor is missing; run closure.py validate before continuing") + detail = ", ".join(status["differences"][:3]) or "plan contract" + raise ClosureError(f"plan anchor drift detected ({detail}); record an approved closure.py amend") + + +def _clear_plan_anchor_blockers(data: dict[str, Any]) -> None: + data["blockers"] = [ + item + for item in _string_list(data.get("blockers")) + if not item.startswith(PLAN_DRIFT_BLOCKER_PREFIX) + ] + + def research_route(data: dict[str, Any]) -> str: """Return the compatible route for a closure task without mutating it.""" value = data.get("research_route") @@ -598,6 +1085,10 @@ def plan_closure( research_change_fields: list[str] | None = None, ) -> list[str]: old_phase = str(data.get("hermes_phase") or "planning") + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + raise ClosureError( + "an approved amendment is pending validation; do not re-plan it" + ) if is_closure_task(data) and old_phase != "planning": raise ClosureError("validated or active plans must change through closure.py amend") ensure_closure_defaults(data, mode) @@ -688,6 +1179,10 @@ def set_research_route( if route is None and research_change_fields is None and not clear_research_changes: raise ClosureError("set --route, --research-change, or --clear-research-changes") ensure_closure_defaults(data) + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + raise ClosureError( + "an approved amendment is pending validation; change the route through amend" + ) if data.get("hermes_phase") != "planning": raise ClosureError("research route can only be marked during planning") if route is not None and route not in RESEARCH_ROUTES: @@ -762,24 +1257,33 @@ def mark_grill_complete( normalized_ref, decision_errors = _validate_decision_ref(task_dir, decision_ref) if decision_errors: raise ClosureError("; ".join(decision_errors)) + pending_errors = _pending_grill_projection_errors(task_dir, data, normalized_ref) + if pending_errors: + raise ClosureError("; ".join(pending_errors)) data["grill_completed"] = True data["decision_ref"] = normalized_ref data["next_action"] = closure_next_action(data) - save_task(task_dir, data) - append_event( + if data.get(PENDING_PLAN_PROJECTION_FIELD) is not None: + _set_pending_plan_projection(task_dir, data) + _save_task_with_events( task_dir, - "grill_completed", - actor=actor, - old_state="pending", - new_state="complete", - reason="exploration grill completed", - extra={"decision_ref": normalized_ref}, + data, + [{ + "event_type": "grill_completed", + "actor": actor, + "old_state": "pending", + "new_state": "complete", + "reason": "exploration grill completed", + "extra": {"decision_ref": normalized_ref}, + }], ) def validate_closure( data: dict[str, Any], task_dir: Path | None = None, + *, + check_plan_anchor: bool = True, ) -> tuple[list[str], list[str]]: errors: list[str] = [] warnings: list[str] = [] @@ -851,6 +1355,13 @@ def validate_closure( errors.append("definition_of_done is required") if _string_list(data.get("blockers")): errors.append("unresolved blockers must be resolved before validation") + if check_plan_anchor and phase != "planning": + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] == "missing": + errors.append("validated plan anchor is missing; run closure.py validate") + elif anchor["status"] != "clean": + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + errors.append(f"plan anchor drift detected: {changed}; use closure.py amend") raw_pins = data.get("context_pins") pins = _string_list(raw_pins) @@ -990,24 +1501,53 @@ def validate_and_ready( ensure_closure_defaults(data) if data.get("closure_state") == "closed": return ["closed tasks must be reopened through closure.py plan"], [] - errors, warnings = validate_closure(data, task_dir) + previous_anchor = plan_anchor_status(data, task_dir) + pending_present = data.get(PENDING_PLAN_PROJECTION_FIELD) is not None + pending_errors = _pending_plan_projection_errors(task_dir, data) + if previous_anchor["status"] == "invalid": + return ["plan anchor is invalid; restore it before validation"], [] + if previous_anchor["status"] == "drifted" and not pending_present: + changed = ", ".join(previous_anchor["differences"][:3]) or "plan contract" + return [f"plan anchor drift detected: {changed}; use closure.py amend"], [] + if pending_errors: + return pending_errors, [] + errors, warnings = validate_closure(data, task_dir, check_plan_anchor=False) if errors: return errors, warnings old_phase = str(data.get("hermes_phase") or "planning") + anchor_created = old_phase == "planning" or previous_anchor["status"] == "missing" + if anchor_created: + data.pop(PENDING_PLAN_PROJECTION_FIELD, None) + data["plan_anchor"] = create_plan_anchor(data, task_dir) + _clear_plan_anchor_blockers(data) _refresh_ready_packages(data) data["hermes_phase"] = "ready" data["status"] = "in_progress" data["next_action"] = closure_next_action(data) - save_task(task_dir, data) + events: list[dict[str, Any]] = [] if old_phase != "ready": - append_event( - task_dir, - "phase_changed", - actor=actor, - old_state=old_phase, - new_state="ready", - reason="closure plan validated", - ) + events.append({ + "event_type": "phase_changed", + "actor": actor, + "old_state": old_phase, + "new_state": "ready", + "reason": "closure plan validated", + }) + if anchor_created: + anchor = data["plan_anchor"] + events.append({ + "event_type": "plan_validated", + "actor": actor, + "old_state": previous_anchor["hash"] or "none", + "new_state": anchor["hash"], + "reason": "closure plan validated and anchored", + "extra": { + "plan_anchor_id": anchor["id"], + "source_revision": anchor["source_revision"], + "plan_anchor": anchor, + }, + }) + _save_task_with_events(task_dir, data, events) return [], warnings @@ -1099,6 +1639,7 @@ def check_package( *, actor: str, ) -> dict[str, Any]: + _require_clean_plan_anchor(task_dir, data) resolved = package_id or str(data.get("current_work_package") or "") if not resolved: raise ClosureError("no current work package") @@ -1139,6 +1680,7 @@ def complete_package( disposition: str = "done", reason: str = "", ) -> dict[str, Any]: + _require_clean_plan_anchor(task_dir, data) if disposition not in DISPOSED_PACKAGE_STATUSES: raise ClosureError("disposition must be done, deferred, or waived") resolved = package_id or str(data.get("current_work_package") or "") @@ -1280,7 +1822,19 @@ def audit_closure( ) -> dict[str, Any]: gaps: list[dict[str, Any]] = [] warnings: list[str] = [] - errors, _ = validate_closure(data, task_dir) + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] == "missing": + gaps.append(_gap(None, "validated plan anchor", "运行 closure.py validate 固定当前计划")) + elif anchor["status"] != "clean": + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + gaps.append( + _gap( + None, + f"plan anchor drift: {changed}", + "记录经批准的 closure.py amend,再重新 validate", + ) + ) + errors, _ = validate_closure(data, task_dir, check_plan_anchor=False) for error in errors: gaps.append(_gap(None, error, "修正 task.json closure plan 后重新 validate")) packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] @@ -1516,6 +2070,9 @@ def repair_closure( ) -> dict[str, Any]: ensure_closure_defaults(data) before = audit_closure(task_dir, data, write_report=False) + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] != "clean": + return before if before["status"] == "all_met": return before has_package_gap = any(gap.get("package") for gap in before.get("gaps") or []) @@ -1572,6 +2129,7 @@ def amend_plan( actor: str, affected_packages: list[str] | None = None, approved_by: str | None = None, + decision_ref: str | None = None, ) -> dict[str, Any]: if not field or not reason.strip(): raise ClosureError("field and change reason are required") @@ -1585,11 +2143,51 @@ def amend_plan( (part for part in research_parts if part in RESEARCH_CHANGE_FIELDS), None, ) + plan_document_change = field == "plan_documents" + if plan_document_change and new_value != "current": + raise ClosureError("plan_documents amendment value must be current") high_risk = ( root_field in HIGH_RISK_FIELDS or risk_field in HIGH_RISK_FIELDS or research_change is not None ) + anchor = plan_anchor_status(data, task_dir) + anchored = anchor["status"] != "missing" + pending_present = data.get(PENDING_PLAN_PROJECTION_FIELD) is not None + pending_errors = _pending_plan_projection_errors(task_dir, data) + if pending_errors: + raise ClosureError("; ".join(pending_errors)) + if anchor["status"] == "invalid": + raise ClosureError("plan anchor is invalid; restore it before amending") + if anchor["status"] == "drifted" and not pending_present: + allowed_drift = _allowed_existing_drift_for_amend(field) + unexpected_drift = [ + item for item in anchor["differences"] if item not in allowed_drift + ] + if unexpected_drift: + raise ClosureError( + "amendment contains unrelated plan drift: " + + ", ".join(unexpected_drift[:3]) + ) + acceptance_change = _is_acceptance_amendment(field) + requires_human_approval = high_risk or ( + anchored and (acceptance_change or plan_document_change) + ) + requires_decision_ref = anchored and ( + high_risk or acceptance_change or plan_document_change + ) + normalized_decision_ref: str | None = None + decision_errors: list[str] = [] + if decision_ref: + normalized_decision_ref, decision_errors = _validate_decision_ref( + task_dir, + decision_ref, + require_plan_change_context=requires_decision_ref, + ) + elif requires_decision_ref: + decision_errors = [ + f"approved plan change requires --decision-ref: {field}" + ] old_phase = str(data.get("hermes_phase") or "planning") old_value = _read_change_value(data, field) incomplete_ids = [ @@ -1604,12 +2202,13 @@ def amend_plan( semantic_ids = [field.split(".", 2)[1]] affected_ids = _dedupe([*semantic_ids, *incomplete_ids]) human_approved = str(approved_by or "").casefold() in {"human", "root", "human/root"} + approval_complete = human_approved and not decision_errors previous_route = research_route(data) previous_changes = _research_change_fields(data) requires_independent_task = ( - research_change == "model_architecture" and human_approved + research_change == "model_architecture" and approval_complete ) - applied = not high_risk or human_approved + applied = not requires_human_approval or approval_complete if requires_independent_task: applied = False data["research_route"] = "exploration" @@ -1626,8 +2225,12 @@ def amend_plan( "complete its grill before validation." ) elif applied: - _write_change_value(data, field, new_value) + if requires_decision_ref: + data["plan_change_ref"] = normalized_decision_ref + if not plan_document_change: + _write_change_value(data, field, new_value) _reset_incomplete_packages(data) + _clear_plan_anchor_blockers(data) data["closure_state"] = "open" data["hermes_phase"] = "planning" data["status"] = "planning" @@ -1642,9 +2245,12 @@ def amend_plan( else: data["next_action"] = "Run closure.py validate after the plan amendment." approval_blocker = f"high-risk plan change requires human approval: {field}" + acceptance_blocker = f"approved plan change requires human approval: {field}" + decision_blocker = f"approved plan change requires decision record: {field}" data["blockers"] = [ item for item in _string_list(data.get("blockers")) - if item != approval_blocker + if item not in {approval_blocker, acceptance_blocker, decision_blocker} + and item != f"approved plan change requires --decision-ref: {field}" ] if field == "closure_mode" and str(new_value) in CLOSURE_MODES: data["max_repair_count"] = default_repair_limit(str(new_value)) @@ -1653,35 +2259,54 @@ def amend_plan( item for item in _string_list(data.get("blockers")) if not item.startswith("repair limit reached") ] + if anchored: + _set_pending_plan_projection(task_dir, data) else: - blocker = f"high-risk plan change requires human approval: {field}" - data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + blockers: list[str] = [] + if requires_human_approval and not human_approved: + prefix = "high-risk" if high_risk else "approved" + blockers.append(f"{prefix} plan change requires human approval: {field}") + if decision_errors: + blockers.extend(decision_errors) + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), *blockers]) data["hermes_phase"] = "blocked" data["status"] = "in_progress" - data["next_action"] = f"Obtain human approval for {field} before applying the amendment." - save_task(task_dir, data) - append_event( - task_dir, - "plan_amended", - actor=actor, - old_state=json.dumps(old_value, ensure_ascii=False, separators=(",", ":")), - new_state=json.dumps(new_value, ensure_ascii=False, separators=(",", ":")), - reason=reason, - extra={ + data["next_action"] = f"Record the decision evidence and obtain human approval for {field} before applying the amendment." + events: list[dict[str, Any]] = [{ + "event_type": "plan_amended", + "actor": actor, + "old_state": json.dumps(old_value, ensure_ascii=False, separators=(",", ":")), + "new_state": json.dumps(new_value, ensure_ascii=False, separators=(",", ":")), + "reason": reason, + "extra": { "affected_packages": affected_ids, "field": field, - "approval_requirement": "human" if high_risk else "none", + "approval_requirement": "human" if requires_human_approval else "none", "approved_by": approved_by, "applied": applied, "research_change": research_change, + "decision_ref": normalized_decision_ref, + "acceptance_change": acceptance_change, + "plan_anchor_differences": anchor["differences"], + "previous_plan_anchor_id": anchor["id"], + "previous_plan_anchor_hash": anchor["hash"], + "proposed_plan_documents": ( + _plan_document_hashes(task_dir, normalized_decision_ref) + if plan_document_change + else None + ), + "pending_projection_hash": ( + _plan_anchor_hash(data[PENDING_PLAN_PROJECTION_FIELD]) + if isinstance(data.get(PENDING_PLAN_PROJECTION_FIELD), dict) + else None + ), }, - ) + }] if requires_independent_task: - append_event( - task_dir, - "research_route_updated", - actor=actor, - old_state=json.dumps( + events.append({ + "event_type": "research_route_updated", + "actor": actor, + "old_state": json.dumps( { "research_route": previous_route, "research_change_fields": previous_changes, @@ -1689,7 +2314,7 @@ def amend_plan( ensure_ascii=False, separators=(",", ":"), ), - new_state=json.dumps( + "new_state": json.dumps( { "research_route": data["research_route"], "research_change_fields": data["research_change_fields"], @@ -1697,18 +2322,18 @@ def amend_plan( ensure_ascii=False, separators=(",", ":"), ), - reason="model architecture change requires an independent exploration task", - ) + "reason": "model architecture change requires an independent exploration task", + }) new_phase = str(data.get("hermes_phase") or old_phase) if new_phase != old_phase: - append_event( - task_dir, - "phase_changed", - actor=actor, - old_state=old_phase, - new_state=new_phase, - reason=f"plan amendment: {field}", - ) + events.append({ + "event_type": "phase_changed", + "actor": actor, + "old_state": old_phase, + "new_state": new_phase, + "reason": f"plan amendment: {field}", + }) + _save_task_with_events(task_dir, data, events) if not applied: write_handoff(task_dir, data, get_repo_root(task_dir)) if requires_independent_task: @@ -1716,8 +2341,11 @@ def amend_plan( "model architecture requires an independent exploration task; " "create a new task and plan it as exploration" ) - if high_risk and not human_approved: - raise ClosureError(f"high-risk change {field} requires --approved-by human/root") + if requires_human_approval and not human_approved: + prefix = "high-risk change" if high_risk else "approved plan change" + raise ClosureError(f"{prefix} {field} requires --approved-by human/root") + if decision_errors: + raise ClosureError("; ".join(decision_errors)) return {"field": field, "old_value": old_value, "new_value": new_value, "high_risk": high_risk} @@ -1815,11 +2443,16 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None excluded = _compact_list(_string_list(data.get("out_of_scope")), 2) blockers = _compact_list(_string_list(data.get("blockers")), 2) constraints = closure_constraints(data) + anchor = plan_anchor_status(data, task_dir) + anchor_label = anchor["id"] or "unvalidated" + anchor_state = anchor["status"] lines = [ f"Task: {data.get('id') or task_dir.name} | {data.get('title') or task_dir.name}", f"Intent: {_clip(str(data.get('intent') or data.get('description') or ''), 180)}", f"Scope: {scope or '-'} | Out: {excluded or '-'}", f"Mode/Phase: {data.get('closure_mode', 'lean')} / {data.get('hermes_phase', 'planning')}", + f"Plan anchor: {anchor_label} / {anchor_state}", + "Definition: " + _compact_list(_string_list(data.get("definition_of_done")), 2, 220), research_route_summary(data), "Route rule: " + research_route_rule(data), f"Verify: {constraints['validation_level']} | Excluded platforms: {_compact_list(constraints['excluded_platforms'], 2) or '-'}", @@ -1851,6 +2484,7 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None ]) changed, integrity_warnings = _task_changed_file_records(task_dir, data) failed_attempts = _recent_event_reasons(task_dir, {"package_blocked", "repair_started"}) + anchor = plan_anchor_status(data, task_dir) content = "\n".join( [ "# Task Handoff", @@ -1861,6 +2495,12 @@ def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None "## Current Goal", str(data.get("intent") or data.get("title") or "-"), "", + "## Plan Anchor", + f"{anchor['id'] or '-'} ({anchor['status']})", + "", + "## Definition of Done", + _markdown_items(_string_list(data.get("definition_of_done"))), + "", "## Current Phase", str(data.get("hermes_phase") or "-"), "", @@ -2142,6 +2782,10 @@ def _next_package_action(data: dict[str, Any]) -> str: def closure_next_action(data: dict[str, Any]) -> str: """Return the one valid next action for the authoritative Hermes phase.""" phase = str(data.get("hermes_phase") or "planning") + if phase != "planning": + anchor = plan_anchor_status(data) + if anchor["status"] in {"drifted", "invalid"}: + return "Plan anchor drift detected; record an approved closure.py amend before continuing." if phase == "planning": if research_route(data) == "exploration": if not _research_change_fields(data): @@ -2270,6 +2914,8 @@ def _validate_amend_field(data: dict[str, Any], field: str) -> None: parts = field.split(".") if any(not part for part in parts): raise ClosureError("amend field path is invalid") + if field == "plan_documents": + return if parts[0] == "work_packages": if len(parts) != 3 or parts[2] not in AMENDABLE_PACKAGE_FIELDS: raise ClosureError("only package title, outcome, done_when, evidence_required, or depends_on may be amended") @@ -2285,6 +2931,17 @@ def _validate_amend_field(data: dict[str, Any], field: str) -> None: raise ClosureError("research amend field path is invalid") +def _is_acceptance_amendment(field: str) -> bool: + if field in {"intent", "definition_of_done"}: + return True + parts = field.split(".") + return ( + len(parts) == 3 + and parts[0] == "work_packages" + and parts[2] in {"outcome", "done_when", "evidence_required"} + ) + + def _research_field_parts(field: str) -> list[str]: parts = field.split(".") if parts[:2] == ["meta", "research_contract"]: @@ -2348,6 +3005,8 @@ def _context_refs(task_dir: Path, data: dict[str, Any], root: Path) -> list[str] def _validate_decision_ref( task_dir: Path, reference: str, + *, + require_plan_change_context: bool = False, ) -> tuple[str, list[str]]: normalized = reference.replace("\\", "/").strip() if ( @@ -2378,22 +3037,32 @@ def _validate_decision_ref( except (OSError, UnicodeDecodeError): return normalized, [f"decision_ref is not readable text: {normalized}"] + required = ( + PLAN_CHANGE_DECISION_SECTION_HEADINGS + if require_plan_change_context + else { + key: value + for key, value in DECISION_SECTION_HEADINGS.items() + if key in {"decision", "rationale", "evidence", "alternatives", "failure conditions"} + } + ) + required_sections = set(required.values()) headings = list(re.finditer(r"(?mi)^#{1,6}\s+(.+?)\s*$", content)) present: set[str] = set() empty: list[str] = [] for index, match in enumerate(headings): key = match.group(1).strip().casefold() canonical = DECISION_SECTION_HEADINGS.get(key) - if canonical is None: + if canonical is None or canonical not in required_sections: continue - present.add(key) + present.add(canonical) end = headings[index + 1].start() if index + 1 < len(headings) else len(content) if not content[match.end():end].strip(): empty.append(canonical) missing = [ canonical - for key, canonical in DECISION_SECTION_HEADINGS.items() - if key not in present + for canonical in required.values() + if canonical not in present ] errors: list[str] = [] if missing: diff --git a/packages/cli/src/templates/trellis/scripts/common/config.py b/packages/cli/src/templates/trellis/scripts/common/config.py index 93df643..247ebd5 100644 --- a/packages/cli/src/templates/trellis/scripts/common/config.py +++ b/packages/cli/src/templates/trellis/scripts/common/config.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -167,6 +168,7 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" DEFAULT_MAX_JOURNAL_LINES = 2000 DEFAULT_SESSION_AUTO_COMMIT = True +DEFAULT_TASK_LIFECYCLE_ENABLED = True CONFIG_FILE = "config.yaml" @@ -243,6 +245,40 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool: return DEFAULT_SESSION_AUTO_COMMIT +def get_task_lifecycle_enabled(repo_root: Path | None = None) -> bool: + """Return whether deterministic Hermes lifecycle reconciliation is enabled.""" + env_value = os.environ.get("TRELLIS_TASK_LIFECYCLE") + if env_value is not None: + normalized = env_value.strip().lower() + if normalized in ("true", "yes", "1", "on"): + return True + if normalized in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid TRELLIS_TASK_LIFECYCLE value: {env_value!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_TASK_LIFECYCLE_ENABLED + + config = _load_config(repo_root) + lifecycle = config.get("task_lifecycle") + if not isinstance(lifecycle, dict): + return DEFAULT_TASK_LIFECYCLE_ENABLED + raw = lifecycle.get("enabled", DEFAULT_TASK_LIFECYCLE_ENABLED) + if isinstance(raw, bool): + return raw + normalized = str(raw).strip().lower() + if normalized in ("true", "yes", "1", "on"): + return True + if normalized in ("false", "no", "0", "off"): + return False + print( + f"[WARN] invalid task_lifecycle.enabled value: {raw!r}; using true (default)", + file=sys.stderr, + ) + return DEFAULT_TASK_LIFECYCLE_ENABLED + + def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: """Get hook commands for a lifecycle event. diff --git a/packages/cli/src/templates/trellis/scripts/common/dispatch.py b/packages/cli/src/templates/trellis/scripts/common/dispatch.py index a2e5d2a..17a577c 100644 --- a/packages/cli/src/templates/trellis/scripts/common/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/common/dispatch.py @@ -19,6 +19,7 @@ is_closure_task, now_utc, package_by_id, + plan_anchor_status, save_task, task_state_lock, write_handoff, @@ -195,6 +196,10 @@ def create_dispatch( if not is_closure_task(task): raise DispatchError("not_hermes_task", "dispatch is available only for Hermes closure tasks") revision = _task_revision(task) + anchor = plan_anchor_status(task, task_dir) + if anchor["status"] in {"drifted", "invalid"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") objective = _required_text(spec.get("objective"), "objective") _check_sensitive_text(objective, "objective") try: @@ -211,6 +216,14 @@ def create_dispatch( role = normalized.role profile = normalized.profile + if anchor["status"] == "missing" and ( + task.get("hermes_phase") != "planning" + or role not in {"planner", "researcher"} + ): + raise DispatchError( + "plan_not_validated", + "formal execution or review requires closure.py validate and a plan anchor", + ) work_package = _normalize_optional_text(spec.get("work_package")) parent_job_id = _normalize_optional_text(spec.get("parent_job_id")) constraints = closure_constraints(task) @@ -293,6 +306,8 @@ def create_dispatch( "task_path": _repo_relative(task_dir, repo_root), "task_revision": revision, "hermes_revision": revision, + "plan_anchor_id": anchor["id"], + "plan_anchor_hash": anchor["hash"], "closure_mode": mode, "constraints": constraints, "role": role, @@ -460,6 +475,23 @@ def validate_dispatch( ) if dispatch.get("task_revision") != dispatch.get("hermes_revision"): errors.append("task_revision") + anchor = plan_anchor_status(task, task_dir) + if anchor["status"] in {"drifted", "invalid"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + raise DispatchError("plan_drift", f"plan anchor drift detected: {changed}; record an approved amendment") + if before_execution and anchor["status"] == "missing" and ( + task.get("hermes_phase") != "planning" + or dispatch.get("role") not in {"planner", "researcher"} + ): + raise DispatchError( + "plan_not_validated", + "formal execution or review requires closure.py validate and a plan anchor", + ) + if anchor["status"] == "clean" and ( + dispatch.get("plan_anchor_id") != anchor["id"] + or dispatch.get("plan_anchor_hash") != anchor["hash"] + ): + raise DispatchError("stale_dispatch", "dispatch plan anchor does not match the approved plan") try: parent_job_id = _normalize_optional_text(dispatch.get("parent_job_id")) if dispatch.get("parent_job_id") != parent_job_id: @@ -624,6 +656,11 @@ def build_canonical_prompt(dispatch: dict[str, Any]) -> str: "Hermes Agent Context Firewall dispatch", f"job_id: {dispatch.get('job_id')}", f"task: {dispatch.get('task_path')} @ revision {dispatch.get('hermes_revision')}", + "plan_anchor: " + ( + f"{dispatch.get('plan_anchor_id')} @ {dispatch.get('plan_anchor_hash')}" + if dispatch.get("plan_anchor_id") and dispatch.get("plan_anchor_hash") + else "legacy-unanchored" + ), f"role: {dispatch.get('role')}:{dispatch.get('profile')}", f"work_package: {dispatch.get('work_package') or 'null'}", f"parent_job_id: {dispatch.get('parent_job_id') or 'null'}", diff --git a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py new file mode 100644 index 0000000..9bc23e1 --- /dev/null +++ b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py @@ -0,0 +1,269 @@ +"""Deterministic, idempotent lifecycle reconciliation for Hermes tasks.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +from .closure import ( + ClosureError, + DISPOSED_PACKAGE_STATUSES, + append_event, + closure_next_action, + create_plan_anchor, + is_closure_task, + plan_anchor_status, + save_task, + task_state_lock, +) +from .config import get_task_lifecycle_enabled +from .io import read_json, write_json_atomic +from .paths import FILE_TASK_JSON, get_developer, get_repo_root + + +ACTIVE_PACKAGE_STATUSES = {"running", "review", "blocked"} + + +def reconcile_task_lifecycle( + task_dir: Path, + data: dict[str, Any], + *, + root: Path | None = None, + actor: str | None = None, + reason: str = "lifecycle boundary", + force: bool = False, + initialize_legacy_anchor: bool = True, +) -> dict[str, Any]: + """Repair only state that can be derived from the current task record. + + This function never accepts evidence, disposes a package, approves a claim, + or closes a task. It is safe to call at every lifecycle boundary. + """ + repo_root = root or get_repo_root(task_dir) + if not force and not get_task_lifecycle_enabled(repo_root): + return _result(data, changed_fields=[], issues=[], enabled=False) + if not is_closure_task(data): + return _result(data, changed_fields=[], issues=[], compatibility=True) + + with task_state_lock(task_dir): + current = read_json(task_dir / FILE_TASK_JSON) + if not isinstance(current, dict): + raise ClosureError("cannot read task.json while reconciling lifecycle") + if not is_closure_task(current): + data.clear() + data.update(current) + return _result(data, changed_fields=[], issues=[], compatibility=True) + + original = deepcopy(current) + changed_fields, issues = _reconcile_in_memory( + current, + task_dir=task_dir, + initialize_legacy_anchor=initialize_legacy_anchor, + ) + if not changed_fields: + data.clear() + data.update(current) + return _result(data, changed_fields=[], issues=issues) + + old_phase = str(original.get("hermes_phase") or "planning") + new_phase = str(current.get("hermes_phase") or "planning") + save_task(task_dir, current, lock_held=True) + event_type = "lifecycle_reconciled" + if any(issue.startswith("plan anchor drift:") for issue in issues): + event_type = "plan_drift_detected" + elif any( + issue in {"legacy plan anchor initialized", "legacy plan anchor upgraded"} + for issue in issues + ): + event_type = "plan_anchor_initialized" + event_extra: dict[str, Any] = { + "changed_fields": changed_fields, + "issues": issues, + } + if event_type == "plan_anchor_initialized": + event_extra["plan_anchor"] = current.get("plan_anchor") + try: + append_event( + task_dir, + event_type, + actor=actor or get_developer(repo_root) or "hermes/lifecycle", + old_state=old_phase, + new_state=new_phase, + package_id=_package_id(current.get("current_work_package")), + reason=reason, + extra=event_extra, + lock_held=True, + ) + except ClosureError: + if not write_json_atomic(task_dir / FILE_TASK_JSON, original): + raise ClosureError( + "lifecycle event failed and task rollback also failed; manual recovery required" + ) + data.clear() + data.update(original) + raise + + data.clear() + data.update(current) + return _result(data, changed_fields=changed_fields, issues=issues) + + +def _reconcile_in_memory( + data: dict[str, Any], + *, + task_dir: Path, + initialize_legacy_anchor: bool, +) -> tuple[list[str], list[str]]: + before = deepcopy(data) + issues: list[str] = [] + packages = [item for item in data.get("work_packages") or [] if isinstance(item, dict)] + package_by_id = { + str(item.get("id")): item + for item in packages + if isinstance(item.get("id"), str) and item.get("id") + } + active = [item for item in packages if item.get("status") in ACTIVE_PACKAGE_STATUSES] + current_id = _package_id(data.get("current_work_package")) + current = package_by_id.get(current_id) if current_id else None + + anchor = plan_anchor_status(data, task_dir) + phase_before_reconcile = str(data.get("hermes_phase") or "planning") + if ( + initialize_legacy_anchor + and anchor["status"] == "missing" + and phase_before_reconcile not in {"planning", "closed"} + ): + data["plan_anchor"] = create_plan_anchor( + data, + task_dir, + origin="legacy_migration", + ) + issues.append("legacy plan anchor initialized") + anchor = plan_anchor_status(data, task_dir) + elif ( + initialize_legacy_anchor + and anchor.get("upgrade_required") is True + and anchor["status"] == "clean" + ): + data["plan_anchor"] = create_plan_anchor( + data, + task_dir, + origin="schema_upgrade", + ) + issues.append("legacy plan anchor upgraded") + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] in {"drifted", "invalid"} and phase_before_reconcile not in {"planning", "closed"}: + changed = ", ".join(anchor["differences"][:3]) or "plan contract" + blocker = f"plan anchor drift: {changed}" + if current is not None and current.get("status") not in DISPOSED_PACKAGE_STATUSES: + current["status"] = "blocked" + current["blocker"] = blocker + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + data["hermes_phase"] = "blocked" + data["status"] = "in_progress" + data["next_action"] = "Record an approved closure.py amend before continuing the task." + issues.append(blocker) + + structural_change = False + if current_id and (current is None or current.get("status") not in ACTIVE_PACKAGE_STATUSES): + data["current_work_package"] = None + current_id = None + current = None + structural_change = True + issues.append("cleared invalid current_work_package") + if current is None and len(active) == 1: + current = active[0] + current_id = str(current.get("id")) + data["current_work_package"] = current_id + structural_change = True + elif len(active) > 1: + issues.append("multiple active work packages require explicit human resolution") + + closed_markers = ( + data.get("closure_state") == "closed", + data.get("hermes_phase") == "closed", + ) + if closed_markers[0] != closed_markers[1]: + data["closure_state"] = "open" + data["hermes_phase"] = "review" + data["status"] = "in_progress" + data["current_work_package"] = None + structural_change = True + issues.append("inconsistent closed markers were reopened instead of auto-closing") + elif all(closed_markers): + data["status"] = "completed" + data["current_work_package"] = None + else: + blocked_packages = [item for item in packages if item.get("status") == "blocked"] + blockers = _string_list(data.get("blockers")) + phase = str(data.get("hermes_phase") or "planning") + if blocked_packages or blockers: + expected_phase = "blocked" + elif current and current.get("status") == "review": + expected_phase = "review" + elif current and current.get("status") == "running": + expected_phase = "running" + elif packages and all( + item.get("status") in DISPOSED_PACKAGE_STATUSES for item in packages + ): + expected_phase = "review" + elif phase == "planning": + expected_phase = "planning" + elif phase == "ready": + expected_phase = "ready" + else: + expected_phase = "running" + if phase != expected_phase: + data["hermes_phase"] = expected_phase + structural_change = True + + expected_status = "planning" if data.get("hermes_phase") == "planning" else "in_progress" + if data.get("status") != expected_status: + data["status"] = expected_status + structural_change = True + + expected_action = closure_next_action(data) + current_action = data.get("next_action") + if structural_change or not isinstance(current_action, str) or not current_action.strip(): + data["next_action"] = expected_action + + changed_fields = sorted( + key for key in set(before) | set(data) if before.get(key) != data.get(key) + ) + return changed_fields, issues + + +def _result( + data: dict[str, Any], + *, + changed_fields: list[str], + issues: list[str], + enabled: bool = True, + compatibility: bool = False, +) -> dict[str, Any]: + return { + "enabled": enabled, + "compatibility": compatibility, + "changed": bool(changed_fields), + "changed_fields": changed_fields, + "issues": issues, + "hermes_revision": data.get("hermes_revision"), + "hermes_phase": data.get("hermes_phase"), + "current_work_package": data.get("current_work_package"), + "next_action": data.get("next_action"), + } + + +def _package_id(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _string_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + + +def _dedupe(values: list[str]) -> list[str]: + return list(dict.fromkeys(values)) diff --git a/packages/cli/src/templates/trellis/scripts/common/task_store.py b/packages/cli/src/templates/trellis/scripts/common/task_store.py index 3d99541..d4e5c2b 100644 --- a/packages/cli/src/templates/trellis/scripts/common/task_store.py +++ b/packages/cli/src/templates/trellis/scripts/common/task_store.py @@ -53,7 +53,8 @@ resolve_task_dir, run_task_hooks, ) -from .closure import closure_defaults, is_closure_task +from .closure import ClosureError, append_event, closure_defaults, is_closure_task, plan_anchor_status +from .lifecycle import reconcile_task_lifecycle # ============================================================================= @@ -94,6 +95,10 @@ def _closure_archive_errors(task_dir: Path, data: dict[str, object]) -> list[str errors.append("task status is not completed") if data.get("current_work_package") is not None: errors.append("a work package is still current") + if data.get("plan_anchor") is not None: + anchor = plan_anchor_status(data, task_dir) + if anchor["status"] != "clean": + errors.append(f"plan anchor is {anchor['status']}") packages = data.get("work_packages") if not isinstance(packages, list) or any( not isinstance(item, dict) or item.get("status") not in {"done", "deferred", "waived"} @@ -367,6 +372,13 @@ def cmd_create(args: argparse.Namespace) -> int: # Output relative path for script chaining print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}") + reconcile_task_lifecycle( + task_dir, + task_data, + root=repo_root, + actor=creator, + reason="task.py create", + ) run_task_hooks("after_create", task_json_path, repo_root) return 0 @@ -410,6 +422,13 @@ def cmd_archive(args: argparse.Namespace) -> int: data = read_json(task_json_path) if data: if is_closure_task(data): + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=get_developer(repo_root) or "hermes/task", + reason="task.py archive", + ) closure_errors = _closure_archive_errors(task_dir, data) if closure_errors: print( @@ -463,6 +482,19 @@ def cmd_archive(args: argparse.Namespace) -> int: year_month = archive_dest.parent.name print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr) + archived_json = archive_dest / FILE_TASK_JSON + try: + append_event( + archive_dest, + "task_archived", + actor=get_developer(repo_root) or "hermes/task", + old_state="closed", + new_state="archived", + reason="task.py archive", + ) + except ClosureError as exc: + print(colored(f"Warning: archived task event was not recorded: {exc}", Colors.YELLOW), file=sys.stderr) + # Auto-commit unless --no-commit if not getattr(args, "no_commit", False): _auto_commit_archive(dir_name, repo_root, modified_children) @@ -471,7 +503,6 @@ def cmd_archive(args: argparse.Namespace) -> int: print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}") # Run hooks with the archived path - archived_json = archive_dest / FILE_TASK_JSON run_task_hooks("after_archive", archived_json, repo_root) return 0 diff --git a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py index 68139f4..cdbe278 100644 --- a/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py +++ b/packages/cli/src/templates/trellis/scripts/hermes/dispatch.py @@ -13,7 +13,8 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.closure import ClosureError, resolve_closure_task +from common.closure import ClosureError, actor_name, plan_anchor_status, resolve_closure_task +from common.lifecycle import reconcile_task_lifecycle from common.dispatch import ( DispatchError, accept_result_text, @@ -128,6 +129,14 @@ def main(argv: list[str] | None = None) -> int: try: task_dir, task, root = resolve_closure_task(args.task) + if args.command == "create" and plan_anchor_status(task, task_dir).get("upgrade_required") is True: + reconcile_task_lifecycle( + task_dir, + task, + root=root, + actor=actor_name(root), + reason="dispatch.py create plan-anchor upgrade", + ) if args.command == "create": spec = { "job_id": args.job_id, diff --git a/packages/cli/src/templates/trellis/scripts/task.py b/packages/cli/src/templates/trellis/scripts/task.py index c84568b..1cbe926 100644 --- a/packages/cli/src/templates/trellis/scripts/task.py +++ b/packages/cli/src/templates/trellis/scripts/task.py @@ -162,6 +162,26 @@ def _validate_hermes_experiment(repo_root, task_dir) -> bool: return False +def _reconcile_hermes_lifecycle(repo_root, task_dir, reason: str): + """Reconcile safe derived closure state and return the current task data.""" + task_json_path = task_dir / FILE_TASK_JSON + data = read_json(task_json_path) if task_json_path.is_file() else None + if not isinstance(data, dict): + return data + from common.closure import is_closure_task + if not is_closure_task(data): + return data + from common.lifecycle import reconcile_task_lifecycle + reconcile_task_lifecycle( + task_dir, + data, + root=repo_root, + actor=get_developer(repo_root) or "hermes/task", + reason=reason, + ) + return data + + def cmd_create(args: argparse.Namespace) -> int: """Create a task, then initialize Hermes when the local Hermes CLI exists.""" repo_root = get_repo_root() @@ -205,7 +225,7 @@ def cmd_start(args: argparse.Namespace) -> int: task_dir = str(full_path) task_json_path = full_path / FILE_TASK_JSON - task_data = read_json(task_json_path) if task_json_path.is_file() else None + task_data = _reconcile_hermes_lifecycle(repo_root, full_path, "task.py start") closure_package_id = None if isinstance(task_data, dict): @@ -302,6 +322,10 @@ def cmd_finish(args: argparse.Namespace) -> int: task_json_path = repo_root / current / FILE_TASK_JSON + task_dir = task_json_path.parent + if task_dir.is_dir(): + _reconcile_hermes_lifecycle(repo_root, task_dir, "task.py finish") + cleared = clear_active_task(repo_root) print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN)) diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 959718d..1a1c201 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -81,7 +81,7 @@ python3 ./.trellis/scripts/task.py create-pr [name] [--dry-run] > 可运行 `python3 ./.trellis/scripts/task.py --help` 查看权威且最新的命令列表。 -**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 +**当前任务机制**:`task.py create` 会创建任务目录,并在存在会话身份时自动设置当前会话的活动任务指针,这样 planning breadcrumb 会立即生效。新任务默认带有 lean closure 字段;先运行 `closure.py plan` 和 `closure.py validate`,再运行 `task.py start`。启动后原版 `task.json.status` 仍从 `planning` 切换为 `in_progress`,Hermes 同时只启动一个 ready 工作包。默认启用的内置生命周期协调器会在任务命令、每轮续接和代理返回边界幂等修复可推导状态;它不接受证据、不处置工作包、不批准主张,也不关闭任务。状态文件保存在 `.trellis/.runtime/sessions/` 下。如果 hook 输入、`TRELLIS_CONTEXT_ID` 或平台原生会话环境变量里都没有上下文 key,则不会有活动任务。`task.py finish` 只删除当前会话文件(不改任务状态)。closure 任务只有在 `closure.py close` 成功后才能 archive;旧版非 closure 任务继续按原版兼容归档,并显示兼容提示。 ### Lean Research Closure @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案文档哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案文档或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts b/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts index 3aa385b..ceccfe4 100644 --- a/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts +++ b/packages/cli/test/commands/init-uninstall-overdelete.integration.test.ts @@ -34,6 +34,7 @@ vi.mock("node:child_process", () => ({ import { init } from "../../src/commands/init.js"; import { uninstall } from "../../src/commands/uninstall.js"; import { update } from "../../src/commands/update.js"; +import { VERSION } from "../../src/constants/version.js"; import { loadHashes, saveHashes } from "../../src/utils/template-hash.js"; import { agentsMdContent } from "../../src/templates/markdown/index.js"; @@ -49,6 +50,13 @@ describe("init + uninstall: manifest accuracy + homedir guard", () => { vi.spyOn(console, "log").mockImplementation(noop); vi.spyOn(console, "error").mockImplementation(noop); vi.mocked(inquirer.prompt).mockResolvedValue({ proceed: true }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: VERSION }), + }), + ); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true, @@ -58,6 +66,7 @@ describe("init + uninstall: manifest accuracy + homedir guard", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); fs.rmSync(tmpDir, { recursive: true, force: true }); delete process.env.TRELLIS_ALLOW_HOMEDIR; }); diff --git a/packages/cli/test/regression.test.ts b/packages/cli/test/regression.test.ts index f03e3a5..4e89ddf 100644 --- a/packages/cli/test/regression.test.ts +++ b/packages/cli/test/regression.test.ts @@ -2991,7 +2991,7 @@ describe("regression: current-task path normalization", () => { next_action?: unknown; }; expect(data.current_phase).toBeUndefined(); - expect(data.next_action).toBeNull(); + expect(data.next_action).toBe("Set intent with closure.py plan."); }); // ------------------------------------------------------------ diff --git a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts index 6b99aa1..e402e26 100644 --- a/packages/cli/test/scripts/agent-context-firewall.integration.test.ts +++ b/packages/cli/test/scripts/agent-context-firewall.integration.test.ts @@ -96,6 +96,7 @@ describe("Agent Context Firewall dispatch CLI", () => { } writeJson(path.join(taskDir, "task.json"), baseTask()); fs.writeFileSync(path.join(taskDir, "prd.md"), "# PRD\n", "utf-8"); + anchorTask(); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "src", "app.ts"), "export {};\n", "utf-8"); const sessions = path.join(root, ".trellis", ".runtime", "sessions"); @@ -164,6 +165,27 @@ describe("Agent Context Firewall dispatch CLI", () => { return run(args); } + function anchorTask(): void { + const result = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor", + "path = Path('.trellis/tasks/demo/task.json')", + "task = json.loads(path.read_text(encoding='utf-8'))", + "task['plan_anchor'] = create_plan_anchor(task, path.parent)", + "path.write_text(json.dumps(task, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(result.status, result.stderr).toBe(0); + } + function apply(jobId: string, value: Record): CommandResult { return run([ "apply", @@ -250,6 +272,63 @@ describe("Agent Context Firewall dispatch CLI", () => { expect(dispatch.audit.metrics.ref_count).toBe(1); }); + it("requires a validated plan before execution dispatch", () => { + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.plan_anchor = null; + writeJson(path.join(taskDir, "task.json"), task); + + const rejected = create("job-unvalidated"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan_not_validated"); + }); + + it("rechecks the plan anchor before an existing dispatch executes", () => { + expect(create("job-anchor-removed").status).toBe(0); + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.plan_anchor = null; + writeJson(path.join(taskDir, "task.json"), task); + + const rejected = run([ + "validate", "--task", "demo", "--job-id", "job-anchor-removed", + ]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan_not_validated"); + }); + + it("binds formal dispatches to the approved plan anchor", () => { + anchorTask(); + expect(create("job-anchor").status).toBe(0); + const dispatch = JSON.parse( + fs.readFileSync( + path.join(taskDir, "hermes", "dispatches", "job-anchor.dispatch.json"), + "utf-8", + ), + ) as { plan_anchor_id: string; plan_anchor_hash: string; body: string }; + expect(dispatch.plan_anchor_id).toMatch(/^pa-/); + expect(dispatch.plan_anchor_hash).toMatch(/^sha256:/); + expect(dispatch.body).toContain("plan_anchor:"); + + const task = JSON.parse(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")); + task.definition_of_done = ["A weaker replacement requirement"]; + writeJson(path.join(taskDir, "task.json"), task); + const rejected = run(["validate", "--task", "demo", "--job-id", "job-anchor"]); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan anchor drift detected"); + }); + + it("rejects dispatch after a validated plan document changes", () => { + anchorTask(); + fs.appendFileSync(path.join(taskDir, "prd.md"), "\nUnapproved plan replacement.\n"); + + const rejected = create("job-document-drift"); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("plan anchor drift detected"); + expect(rejected.stderr).toContain("plan_documents"); + expect(fs.existsSync( + path.join(taskDir, "hermes", "dispatches", "job-document-drift.dispatch.json"), + )).toBe(false); + }); + it("permits a coder configuration dispatch only for the current task handoff", () => { const result = run([ "create", @@ -474,6 +553,7 @@ describe("Agent Context Firewall dispatch CLI", () => { }, }), ); + anchorTask(); const excluded = create("job-excluded-platform", "coder", [ "--platform", "codex", @@ -503,6 +583,7 @@ describe("Agent Context Firewall dispatch CLI", () => { it("allows publication packet creation with the compact protocol", () => { writeJson(path.join(taskDir, "task.json"), baseTask({ closure_mode: "publication" })); + anchorTask(); const result = run( [ "create", "--task", "demo", "--job-id", "job-publication", "--role", "planner", @@ -752,6 +833,7 @@ describe("Agent Context Firewall dispatch CLI", () => { ); writeJson(path.join(taskDir, "task.json"), baseTask()); + anchorTask(); expect(create("job-async").status).toBe(0); const asyncResult = runHook("inject-subagent-context.py", { cwd: root, diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 3d25b22..1dd6d2e 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -208,6 +208,15 @@ function writeDecisionRecord( "## Evidence", "Repository evidence supports this bounded choice.", "", + "## Original Commitment", + "Preserve the validated task intent and acceptance contract.", + "", + "## Failure Evidence", + "The recorded audit gap or failed validation shows why the previous plan cannot continue unchanged.", + "", + "## Failed Attempts", + "The bounded repair was attempted before proposing this amendment.", + "", "## Alternatives", "Keep the previous protocol.", "", @@ -301,7 +310,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect( run(root, "grill", "--complete", "--decision-ref", decisionRef).status, ).toBe(0); - expect(run(root, "validate").status).toBe(0); + const revalidated = run(root, "validate"); + expect(revalidated.status, revalidated.stderr).toBe(0); const capsule = run(root, "capsule"); expect(capsule.stdout).toContain("Route: exploration"); expect(capsule.stdout).toContain("Research changes: model_architecture"); @@ -490,6 +500,267 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(capsule.stdout).toContain("Refs: .trellis/spec/guides/index.md"); }); + it("anchors a validated plan and exposes the contract in the capsule", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const anchored = readTask(taskDir) as TestTask & { + plan_anchor: { id: string; hash: string; projection: Record }; + }; + expect(anchored.plan_anchor.id).toMatch(/^pa-/); + expect(anchored.plan_anchor.projection.definition_of_done).toEqual(["Artifact exists"]); + expect(run(root, "capsule").stdout).toContain("Plan anchor:"); + expect(run(root, "capsule").stdout).toContain("Definition: Artifact exists"); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).toContain('"event_type":"plan_validated"'); + }); + + it("blocks unrecorded plan drift before package review or closure", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const mutated = readTask(taskDir); + mutated.work_packages[0].done_when = ["Only a file exists"]; + writeTask(taskDir, mutated); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("plan anchor drift detected"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + const events = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(events).toContain('"event_type":"plan_drift_detected"'); + }); + + it("blocks direct changes to validated plan documents", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.appendFileSync(path.join(taskDir, "prd.md"), "\nUnapproved scope change.\n"); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("plan anchor drift detected"); + expect(result.stderr).toContain("plan_documents"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + }); + + it("blocks direct changes to the validated research contract", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const mutated = readTask(taskDir); + mutated.meta.research_contract = { dataset: "unapproved-dataset" }; + writeTask(taskDir, mutated); + + const result = run(root, "package-check"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("research_contract.dataset"); + expect(readTask(taskDir).hermes_phase).toBe("blocked"); + }); + + it("allows implementation checklist progress without changing the plan anchor", () => { + fs.writeFileSync( + path.join(taskDir, "implement.md"), + "# Implementation\n\n- [ ] Run the bounded implementation.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.writeFileSync( + path.join(taskDir, "implement.md"), + "# Implementation\n\n- [x] Run the bounded implementation.\n", + "utf-8", + ); + + const result = run(root, "package-check"); + expect(result.status, result.stderr).toBe(0); + }); + + it("records an approved plan-document amendment and preserves anchor history", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = readTask(taskDir) as TestTask & { plan_anchor: { hash: string } }; + fs.appendFileSync(path.join(taskDir, "prd.md"), "\nApproved acceptance clarification.\n"); + const decisionRef = writeDecisionRecord(taskDir, "document-amendment.md"); + + const denied = run( + root, + "amend", + "--field", + "plan_documents", + "--value", + "current", + "--reason", + "review evidence requires a documented clarification", + "--decision-ref", + decisionRef, + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("requires --approved-by"); + + const amended = run( + root, + "amend", + "--field", + "plan_documents", + "--value", + "current", + "--reason", + "review evidence requires a documented clarification", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ); + expect(amended.status, amended.stderr).toBe(0); + expect(run(root, "validate").status).toBe(0); + const after = readTask(taskDir) as TestTask & { + plan_anchor: { hash: string; projection: { plan_documents: unknown[] } }; + plan_change_ref?: string; + }; + expect(after.plan_anchor.hash).not.toBe(before.plan_anchor.hash); + expect(after.plan_anchor.projection.plan_documents.length).toBeGreaterThan(0); + expect(after.plan_change_ref).toBe(decisionRef); + + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const anchors = events.filter((event) => event.event_type === "plan_validated"); + expect(anchors).toHaveLength(2); + expect((anchors[0].plan_anchor as { hash: string }).hash).toBe(before.plan_anchor.hash); + expect((anchors[1].plan_anchor as { hash: string }).hash).toBe(after.plan_anchor.hash); + }); + + it("requires a decision record and human approval to change validated acceptance", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = (readTask(taskDir) as TestTask & { + plan_anchor: { hash: string }; + }).plan_anchor.hash; + + const denied = run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "strengthen acceptance after review", + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("approved plan change definition_of_done requires --approved-by"); + + const decisionRef = writeDecisionRecord(taskDir, "acceptance-amendment.md"); + expect( + run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "strengthen acceptance after review", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ).status, + ).toBe(0); + const acceptanceValidated = run(root, "validate"); + expect(acceptanceValidated.status, acceptanceValidated.stderr).toBe(0); + const after = readTask(taskDir) as TestTask & { plan_anchor: { hash: string } }; + expect(after.plan_anchor.hash).not.toBe(before); + }); + + it("rejects unrelated changes added after an approved amendment", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const decisionRef = writeDecisionRecord(taskDir, "acceptance-projection.md"); + expect( + run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "approved acceptance refinement", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ).status, + ).toBe(0); + + const tampered = readTask(taskDir) as TestTask & { in_scope?: string[] }; + tampered.in_scope = ["unapproved extra scope"]; + writeTask(taskDir, tampered); + const validation = run(root, "validate"); + expect(validation.status).toBe(1); + expect(validation.stderr).toContain("unapproved changes after plan amendment"); + expect(validation.stderr).toContain("in_scope"); + }); + + it("requires failure context before an approved validated plan change", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const decisionRef = "incomplete-amendment.md"; + fs.writeFileSync( + path.join(taskDir, decisionRef), + [ + "# Research Decision", + "", + "## Decision", + "Tighten the acceptance condition.", + "", + "## Rationale", + "Review found an untested behavior.", + "", + "## Evidence", + "The review report identifies the missing test.", + "", + "## Alternatives", + "Leave the acceptance condition unchanged.", + "", + "## Failure Conditions", + "The added validation still does not cover the required behavior.", + "", + ].join("\n"), + "utf-8", + ); + + const denied = run( + root, + "amend", + "--field", + "definition_of_done", + "--value", + '["Artifact exists and behavior is exercised"]', + "--reason", + "review exposed an acceptance gap", + "--approved-by", + "human/root", + "--decision-ref", + decisionRef, + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("Original Commitment"); + expect(denied.stderr).toContain("Failure Evidence"); + expect(denied.stderr).toContain("Failed Attempts"); + expect(readTask(taskDir).definition_of_done).toEqual(["Artifact exists"]); + }); + it("keeps command and file microsteps inside done_when", () => { expect( plan(root, [ @@ -868,6 +1139,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "one reviewed retry", ).status, ).toBe(1); + const decisionRef = writeDecisionRecord(taskDir, "repair-limit-amendment.md"); expect( run( root, @@ -880,6 +1152,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "one reviewed retry", "--approved-by", "human/root", + "--decision-ref", + decisionRef, ).status, ).toBe(0); const task = readTask(taskDir); @@ -891,6 +1165,26 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(run(root, "validate").status).toBe(0); }); + it("requires a recorded decision for closure mode changes", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const denied = run( + root, + "amend", + "--field", + "closure_mode", + "--value", + "standard", + "--reason", + "formal validation is now required", + "--approved-by", + "human/root", + ); + expect(denied.status).toBe(1); + expect(denied.stderr).toContain("requires --decision-ref"); + expect(readTask(taskDir)).toMatchObject({ closure_mode: "lean" }); + }); + it("requires explicit human approval for high-risk amendments", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -908,6 +1202,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).meta.research_contract?.dataset).toBeUndefined(); expect(readTask(taskDir).hermes_phase).toBe("blocked"); + const amendmentRef = writeDecisionRecord(taskDir, "dataset-amendment.md"); + const approved = run( root, "amend", @@ -919,6 +1215,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "approved source", "--approved-by", "human/root", + "--decision-ref", + amendmentRef, ); expect(approved.status).toBe(0); const task = readTask(taskDir); @@ -930,9 +1228,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.grill_completed).toBe(false); expect(run(root, "validate").status).toBe(1); const decisionRef = writeDecisionRecord(taskDir); - expect( - run(root, "grill", "--complete", "--decision-ref", decisionRef).status, - ).toBe(0); + const grilled = run(root, "grill", "--complete", "--decision-ref", decisionRef); + expect(grilled.status, grilled.stderr).toBe(0); expect(run(root, "validate").status).toBe(0); }); @@ -989,6 +1286,10 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { grill_completed: true, }); + const restored = readTask(taskDir); + restored.meta.research_contract = {}; + writeTask(taskDir, restored); + const approved = run( root, "amend", @@ -1000,6 +1301,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "approved split change", "--approved-by", "human/root", + "--decision-ref", + writeDecisionRecord(taskDir, "split-amendment.md"), ); expect(approved.status).toBe(0); expect(readTask(taskDir)).toMatchObject({ @@ -1008,6 +1311,23 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); }); + it("keeps a drift-blocked package unchanged when repair is requested", () => { + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + const drifted = readTask(taskDir); + drifted.work_packages[0].done_when = ["A weaker result exists"]; + writeTask(taskDir, drifted); + expect(run(root, "package-check").status).toBe(1); + const beforeRepair = structuredClone(readTask(taskDir).work_packages[0]); + + const repair = run(root, "repair"); + expect(repair.status).toBe(1); + const afterRepair = readTask(taskDir); + expect(afterRepair.hermes_phase).toBe("blocked"); + expect(afterRepair.work_packages[0]).toEqual(beforeRepair); + }); + it("treats functional model architecture changes as high risk", () => { expect(plan(root, ["Result A"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -1042,6 +1362,8 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { "new model behavior", "--approved-by", "human/root", + "--decision-ref", + writeDecisionRecord(taskDir, "architecture-amendment.md"), ); expect(result.status).toBe(1); expect(result.stderr).toContain("independent exploration task"); @@ -1098,6 +1420,34 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { path.join(root, ".trellis", "tasks", "archive", archiveMonth, "demo"), ), ).toBe(true); + const archivedEvents = fs.readFileSync( + path.join( + root, + ".trellis", + "tasks", + "archive", + archiveMonth, + "demo", + "hermes", + "task-events.jsonl", + ), + "utf-8", + ); + expect(archivedEvents).toContain('"event_type":"task_archived"'); + }); + + it("refuses archive when a closed task no longer matches its plan anchor", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "anchored-close"); + expect(run(root, "close").status).toBe(0); + const changed = readTask(taskDir); + changed.definition_of_done = ["A weaker post-close requirement"]; + writeTask(taskDir, changed); + + const archive = runTask(root, "archive", "demo", "--no-commit"); + expect(archive.status).toBe(1); + expect(archive.stderr).toContain("plan anchor is drifted"); }); it("refuses archive when closure fields are forged without a close event", () => { @@ -1120,6 +1470,41 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(fs.existsSync(taskDir)).toBe(true); }); + it("rolls back validation when its semantic event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + const beforeValidation = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + fs.mkdirSync(eventPath, { recursive: true }); + + const result = run(root, "validate"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("failed to read task event log"); + expect(readTask(taskDir)).toEqual(beforeValidation); + }); + + it("rolls back an amendment when its semantic event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const beforeAmendment = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + fs.rmSync(eventPath); + fs.mkdirSync(eventPath); + + const result = run( + root, + "amend", + "--field", + "work_packages.WP1.title", + "--value", + "Renamed result package", + "--reason", + "clarify the package outcome", + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("failed to read task event log"); + expect(readTask(taskDir)).toEqual(beforeAmendment); + }); + it("rolls back task state when recording the close event fails", () => { expect(plan(root, ["Lean result verified"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); diff --git a/packages/cli/test/scripts/lifecycle.integration.test.ts b/packages/cli/test/scripts/lifecycle.integration.test.ts new file mode 100644 index 0000000..abc5b3a --- /dev/null +++ b/packages/cli/test/scripts/lifecycle.integration.test.ts @@ -0,0 +1,244 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; +const TEMPLATE_SCRIPTS = path.resolve( + import.meta.dirname, + "../../src/templates/trellis/scripts", +); + +function hasPython(): boolean { + try { + execFileSync(PYTHON, ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function task(overrides: Record = {}) { + return { + id: "demo", + title: "Lifecycle demo", + status: "in_progress", + hermes_phase: "running", + closure_state: "open", + closure_mode: "lean", + intent: "Produce a verified result", + in_scope: ["demo"], + out_of_scope: [], + definition_of_done: ["Result is verified"], + work_packages: [ + { + id: "WP1", + title: "Verified result", + outcome: "A verified result exists", + done_when: ["Result is verified"], + evidence_required: [], + depends_on: [], + status: "running", + evidence_refs: [], + blocker: null, + }, + ], + current_work_package: "WP1", + next_action: "Complete WP1 outcome, then run closure.py package-check.", + blockers: [], + repair_count: 0, + max_repair_count: 1, + hermes_revision: 0, + ...overrides, + }; +} + +describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { + let root: string; + let taskDir: string; + let taskJson: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "trellis-lifecycle-")); + taskDir = path.join(root, ".trellis", "tasks", "demo"); + taskJson = path.join(taskDir, "task.json"); + fs.mkdirSync(path.join(root, ".trellis", "scripts"), { recursive: true }); + fs.cpSync(TEMPLATE_SCRIPTS, path.join(root, ".trellis", "scripts"), { + recursive: true, + }); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync(path.join(root, ".trellis", ".developer"), "name=tester\n"); + fs.writeFileSync( + path.join(root, ".trellis", "config.yaml"), + "task_lifecycle:\n enabled: true\nsession_auto_commit: false\n", + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeTask(value: Record): void { + fs.writeFileSync(taskJson, `${JSON.stringify(value, null, 2)}\n`); + } + + function readTask(): ReturnType { + return JSON.parse( + fs.readFileSync(taskJson, "utf-8"), + ) as ReturnType; + } + + function reconcile(env: Record = {}) { + return spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.io import read_json", + "from common.lifecycle import reconcile_task_lifecycle", + "task_dir = Path('.trellis/tasks/demo')", + "data = read_json(task_dir / 'task.json')", + "result = reconcile_task_lifecycle(task_dir, data, root=Path('.').resolve(), actor='test', reason='test boundary')", + "print(json.dumps(result))", + ].join("; "), + ], + { + cwd: root, + encoding: "utf-8", + env: { ...process.env, ...env }, + }, + ); + } + + it("repairs derived state once and remains idempotent", () => { + writeTask(task({ + status: "completed", + current_work_package: null, + next_action: null, + })); + + const first = reconcile(); + expect(first.status, first.stderr).toBe(0); + const repaired = readTask(); + expect(repaired.status).toBe("in_progress"); + expect(repaired.current_work_package).toBe("WP1"); + expect(repaired.next_action).toContain("package-check"); + expect( + (repaired as typeof repaired & { plan_anchor?: { origin?: string } }).plan_anchor, + ).toMatchObject({ origin: "legacy_migration" }); + expect(repaired.hermes_revision).toBe(1); + + const second = reconcile(); + expect(second.status, second.stderr).toBe(0); + expect(readTask().hermes_revision).toBe(1); + const events = fs + .readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8") + .trim() + .split("\n"); + expect(events).toHaveLength(1); + expect(events[0]).toContain('"event_type":"plan_anchor_initialized"'); + }); + + it("is enabled by default and can be explicitly disabled", () => { + writeTask(task({ status: "completed", current_work_package: null })); + fs.writeFileSync( + path.join(root, ".trellis", "config.yaml"), + "session_auto_commit: false\n", + ); + expect(reconcile().status).toBe(0); + expect(readTask().status).toBe("in_progress"); + + writeTask(task({ + status: "completed", + current_work_package: null, + hermes_revision: 0, + })); + expect(reconcile({ TRELLIS_TASK_LIFECYCLE: "0" }).status).toBe(0); + const disabled = readTask(); + expect(disabled.status).toBe("completed"); + expect(disabled.hermes_revision).toBe(0); + }); + + it("upgrades a clean legacy plan anchor once and records the full snapshot", () => { + writeTask(task()); + const legacy = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor, _plan_anchor_hash", + "path = Path('.trellis/tasks/demo/task.json')", + "data = json.loads(path.read_text(encoding='utf-8'))", + "anchor = create_plan_anchor(data)", + "anchor['schema'] = 'hermes-plan-anchor/v1'", + "anchor['projection'].pop('plan_documents', None)", + "anchor['projection'].pop('plan_change_ref', None)", + "anchor['hash'] = _plan_anchor_hash(anchor['projection'])", + "data['plan_anchor'] = anchor", + "path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(legacy.status, legacy.stderr).toBe(0); + + expect(reconcile().status).toBe(0); + const upgraded = readTask() as ReturnType & { + plan_anchor: { schema: string; origin: string; projection: { plan_documents: unknown[] } }; + }; + expect(upgraded.plan_anchor.schema).toBe("hermes-plan-anchor/v2"); + expect(upgraded.plan_anchor.origin).toBe("schema_upgrade"); + expect(upgraded.plan_anchor.projection.plan_documents).toBeDefined(); + expect(upgraded.hermes_revision).toBe(1); + const event = JSON.parse( + fs.readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8").trim(), + ) as { event_type: string; plan_anchor?: { schema?: string } }; + expect(event.event_type).toBe("plan_anchor_initialized"); + expect(event.plan_anchor?.schema).toBe("hermes-plan-anchor/v2"); + + expect(reconcile().status).toBe(0); + expect(readTask().hermes_revision).toBe(1); + }); + + it("never accepts evidence, disposes a package, or closes a task", () => { + writeTask(task({ + hermes_phase: "review", + current_work_package: "WP1", + work_packages: [ + { + ...task().work_packages[0], + status: "review", + evidence_refs: ["artifacts/result.json"], + confirmed_dispatches: ["job-1"], + }, + ], + })); + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.work_packages[0].status).toBe("review"); + expect(current.closure_state).toBe("open"); + expect(current.status).toBe("in_progress"); + }); + + it("reopens inconsistent close markers instead of completing the task", () => { + writeTask(task({ + status: "completed", + closure_state: "closed", + hermes_phase: "review", + current_work_package: null, + work_packages: [{ ...task().work_packages[0], status: "done" }], + })); + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.closure_state).toBe("open"); + expect(current.hermes_phase).toBe("review"); + expect(current.status).toBe("in_progress"); + }); +}); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index 31c3439..c442917 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -109,6 +109,29 @@ function setupMainRepo( writeTaskArtifacts(repoRoot, taskName, prd, implementPlan); } +function anchorClosureTask(repoRoot: string, taskName: string): void { + const result = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor", + `path = Path('.trellis/tasks/${taskName}/task.json')`, + "task = json.loads(path.read_text(encoding='utf-8'))", + "task['plan_anchor'] = create_plan_anchor(task, path.parent)", + "path.write_text(json.dumps(task, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: repoRoot, encoding: "utf-8" }, + ); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || "plan anchor creation failed"); + } +} + function setSessionActiveTask( repoRoot: string, taskName: string, @@ -813,7 +836,8 @@ describe("shared-hooks capability table", () => { /\n([\s\S]*?)\n<\/task-resume>/.exec( longAction.hookSpecificOutput.additionalContext, )?.[1] ?? ""; - expect(resume).toContain("PACKAGE_HEAD"); + expect(resume).toContain("current package=WP1"); + expect(resume).not.toContain("PACKAGE_HEAD"); expect(resume).not.toContain("PACKAGE_TAIL"); expect(resume.length).toBeLessThanOrEqual(800); } finally { @@ -1224,6 +1248,7 @@ describe.skipIf(!hasPython())( for (const name of ["direct.md", "second.md", "third.md", "fourth.md"]) { fs.writeFileSync(path.join(repoRoot, "docs", name), name, "utf-8"); } + anchorClosureTask(repoRoot, taskName); const created = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "review-source", "--role", "researcher", "--profile", "codebase", @@ -1282,6 +1307,7 @@ describe.skipIf(!hasPython())( current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + anchorClosureTask(repoRoot, taskName); const source = runDispatchCli(repoRoot, [ "create", "--task", taskName, "--job-id", "legacy-source", "--role", "researcher", "--profile", "codebase", @@ -1615,6 +1641,7 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { current_work_package: null, }); fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + anchorClosureTask(repoRoot, taskName); setSessionActiveTask(repoRoot, taskName); }); From 51c6b8b5bf8f8e5422b7b1e9cfdff93955bb2277 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Sat, 25 Jul 2026 01:25:22 +0800 Subject: [PATCH 10/13] fix(hermes): harden lifecycle closure integrity --- docs/manual/17-lean-research-closure.md | 4 +- docs/manual/appendix-d-record-schema.md | 2 +- marketplace/workflows/native/workflow.md | 2 +- .../trellis/scripts/common/closure.py | 61 +++++++++- .../trellis/scripts/common/lifecycle.py | 106 ++++++++++++++++-- .../trellis/scripts/common/task_store.py | 40 ++++++- .../cli/src/templates/trellis/workflow.md | 2 +- .../test/scripts/closure.integration.test.ts | 63 ++++++++++- .../scripts/lifecycle.integration.test.ts | 80 ++++++++++++- .../cli/test/templates/shared-hooks.test.ts | 14 +++ 10 files changed, 346 insertions(+), 28 deletions(-) diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 4b4ee49..3a9d06e 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -69,13 +69,13 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit ## 计划锚点与方案漂移 -每次 `validate`(校验)成功都会在 `task.json`(任务状态)生成 `plan_anchor`(计划锚点)。它固定当时的 intent(目标)、范围、完成定义、工作包结果与验收、证据要求、研究路线、关键约束,以及 `prd.md`(需求文档)、`design.md`(设计方案)、`implement.md`(实施计划)和当前决策记录的文件哈希。完整锚点还随 `plan_validated`(计划已校验)事件追加保存,因此重新校验不会抹掉旧方案。它不是第二份当前状态;当前状态仍以 `task.json` 为准,锚点只是“哪一版方案得到批准”的基线。 +每次 `validate`(校验)成功都会在 `task.json`(任务状态)生成 `plan_anchor`(计划锚点)。它固定当时的 intent(目标)、范围、完成定义、工作包结果与验收、证据要求、研究路线、关键约束,以及 `prd.md`(需求文档)、`design.md`(设计方案)、`implement.md`(实施计划)中方案契约部分和当前决策记录的哈希。`Notes`(说明)、`Progress`(进度)、`Experiment Log`(实验记录)等专门章节用于追加执行记录,不会改变锚点;其他方案正文仍受保护。完整锚点还随 `plan_validated`(计划已校验)事件追加保存,因此重新校验不会抹掉旧方案。它不是第二份当前状态;当前状态仍以 `task.json` 为准,锚点只是“哪一版方案得到批准”的基线。 已有的 Hermes 任务若已在 ready、running、review 或 blocked 阶段但没有锚点,首次生命周期续接会生成一次 `legacy_migration`(旧任务迁移)锚点并记录事件;原版非 Hermes 任务不受影响。 在 ready、running 或 review 阶段,如果这些内容被直接改动,生命周期协调会写入 `plan_drift_detected`(计划漂移已发现)事件并进入 `blocked`(阻塞)。`package-check`(工作包检查)、`package-done`(工作包处置)、正式 agent 派发、`audit`(审计)、`close`(关闭)都会拒绝继续。这样“当前报错所以换一个方案”的判断不会悄悄替换原计划。 -遇到困难时先在原计划内复现和定位;有限 repair(修复)仍只处理审计缺口。需要改变目标、完成定义、工作包 outcome(结果)、`done_when`(完成条件)、证据要求、范围、研究协议或方案文档时,使用 `amend`(计划变更)。方案文件先修改,再用 `amend --field plan_documents --value current`(接受当前方案文件)确认这一版。已经校验的验收、研究协议和方案文件变化必须同时提供人工批准与 `decision_ref`(决策记录引用);记录必须有 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Original Commitment`(原承诺)、`Failure Evidence`(失败证据)、`Failed Attempts`(已尝试路径)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。完成变更后任务回到 planning,再次 `validate` 才会生成新锚点。 +遇到困难时先在原计划内复现和定位;有限 repair(修复)仍只处理审计缺口。需要改变目标、完成定义、工作包 outcome(结果)、`done_when`(完成条件)、证据要求、范围、研究协议或方案正文时,使用 `amend`(计划变更)。方案文件先修改,再用 `amend --field plan_documents --value current`(接受当前方案文件)确认这一版。已经校验的验收、研究协议和方案文件变化必须同时提供人工批准与 `decision_ref`(决策记录引用);记录必须有 `Decision`(决定)、`Rationale`(理由)、`Evidence`(证据)、`Original Commitment`(原承诺)、`Failure Evidence`(失败证据)、`Failed Attempts`(已尝试路径)、`Alternatives`(备选方案)和 `Failure Conditions`(失效条件)。完成变更后任务回到 planning,再次 `validate` 才会生成新锚点。 模型架构、新假设、数据、切分、预处理、指标、基线或主张范围变化属于探索性研究;其中模型架构变化必须创建独立探索任务,不会覆盖旧任务结果。旧任务应明确保留为完成、延期、阻塞或失败,而不是把新路线写成原方案已经完成。 diff --git a/docs/manual/appendix-d-record-schema.md b/docs/manual/appendix-d-record-schema.md index 38b1727..5dc897f 100644 --- a/docs/manual/appendix-d-record-schema.md +++ b/docs/manual/appendix-d-record-schema.md @@ -28,7 +28,7 @@ | `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` | -`hermes/task-events.jsonl`(任务事件记录)还包含 `phase_changed`(阶段变化)、`plan_validated`(计划已校验)、`plan_anchor_initialized`(计划锚点已初始化)、`plan_drift_detected`(计划漂移已发现)、`package_started`(工作包启动)、`package_completed`(工作包完成)、`package_blocked`(工作包阻塞)、`lifecycle_reconciled`(生命周期校正)、`task_closed`(任务关闭)和 `task_archived`(任务归档)。`plan_validated`(计划已校验)保存当次完整计划锚点,包括方案字段和方案文件哈希;后续重新校验会追加新事件,不覆盖旧方案。生命周期校正事件额外记录实际变化字段和发现的不一致;没有状态变化时不会追加事件。 +`hermes/task-events.jsonl`(任务事件记录)还包含 `phase_changed`(阶段变化)、`plan_validated`(计划已校验)、`plan_anchor_initialized`(计划锚点已初始化)、`plan_drift_detected`(计划漂移已发现)、`package_started`(工作包启动)、`package_completed`(工作包完成)、`package_blocked`(工作包阻塞)、`lifecycle_reconciled`(生命周期校正)、`task_closed`(任务关闭)和 `task_archived`(任务归档)。`plan_validated`(计划已校验)保存当次完整计划锚点,包括方案字段和方案契约部分的文件哈希;后续重新校验会追加新事件,不覆盖旧方案。生命周期校正事件额外记录实际变化字段和发现的不一致;没有状态变化时不会追加事件。 正式角色还有两种 JSON 文件,不是 `JSONL`(逐行 JSON): diff --git a/marketplace/workflows/native/workflow.md b/marketplace/workflows/native/workflow.md index 1a1c201..3e23a44 100644 --- a/marketplace/workflows/native/workflow.md +++ b/marketplace/workflows/native/workflow.md @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案文档哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案文档或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案契约部分哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;`Notes`(说明)、`Progress`(进度)和 `Experiment Log`(实验记录)可追加执行记录而不改变方案;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案正文或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index 9f93762..ee704f1 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -74,11 +74,30 @@ "waived", } DISPOSED_PACKAGE_STATUSES = {"done", "deferred", "waived"} -PLAN_ANCHOR_SCHEMA = "hermes-plan-anchor/v2" -LEGACY_PLAN_ANCHOR_SCHEMAS = {"hermes-plan-anchor/v1"} +PLAN_ANCHOR_SCHEMA = "hermes-plan-anchor/v3" +LEGACY_PLAN_ANCHOR_SCHEMAS = {"hermes-plan-anchor/v1", "hermes-plan-anchor/v2"} PLAN_DOCUMENT_NAMES = ("prd.md", "design.md", "implement.md") PLAN_DRIFT_BLOCKER_PREFIX = "plan anchor drift:" PENDING_PLAN_PROJECTION_FIELD = "pending_plan_projection" +NON_CONTRACT_PLAN_SECTION_HEADINGS = { + "notes", + "progress", + "experiment log", + "experiments", + "run log", + "results", + "observations", + "changelog", + "status", + "备注", + "说明", + "进度", + "实验记录", + "运行记录", + "结果记录", + "观察记录", + "变更记录", +} HIGH_RISK_FIELDS = { "closure_mode", "hypothesis", @@ -470,7 +489,14 @@ def plan_anchor_status( ) if schema in LEGACY_PLAN_ANCHOR_SCHEMAS or incomplete_current_schema: current = plan_anchor_projection(data, task_dir) - if schema in LEGACY_PLAN_ANCHOR_SCHEMAS: + if schema == "hermes-plan-anchor/v2": + current["plan_documents"] = _plan_document_hashes( + task_dir, + data.get("decision_ref"), + data.get("plan_change_ref"), + legacy_full_text=True, + ) + if schema == "hermes-plan-anchor/v1": current.pop("plan_documents", None) if "research_contract" not in projection and not current.get("research_contract"): current.pop("research_contract", None) @@ -602,6 +628,7 @@ def _normalize_contract_value(value: Any) -> Any: def _plan_document_hashes( task_dir: Path | None, *decision_refs: Any, + legacy_full_text: bool = False, ) -> list[dict[str, Any]]: if task_dir is None: return [] @@ -637,7 +664,11 @@ def _plan_document_hashes( except (OSError, UnicodeDecodeError, ValueError): records.append({"path": ref, "status": "unreadable", "hash": None}) continue - normalized = _normalize_plan_document(content) + normalized = _normalize_plan_document( + content, + path=path, + legacy_full_text=legacy_full_text, + ) records.append({ "path": path, "status": "present", @@ -646,10 +677,30 @@ def _plan_document_hashes( return sorted(records, key=lambda item: str(item["path"])) -def _normalize_plan_document(content: str) -> str: +def _normalize_plan_document( + content: str, + *, + path: str, + legacy_full_text: bool = False, +) -> str: + """Hash plan commitments while allowing append-only work notes.""" lines = [] + excluded_section_level: int | None = None for raw in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"): line = raw.rstrip() + if not legacy_full_text and path in PLAN_DOCUMENT_NAMES: + heading = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", line) + if heading: + level = len(line.lstrip()) - len(line.lstrip().lstrip("#")) + if excluded_section_level is not None and level <= excluded_section_level: + excluded_section_level = None + normalized_heading = " ".join(heading.group(1).casefold().split()) + if excluded_section_level is None and normalized_heading in NON_CONTRACT_PLAN_SECTION_HEADINGS: + excluded_section_level = level + if excluded_section_level is not None: + continue + if excluded_section_level is not None: + continue line = re.sub(r"^(\s*[-*+]\s*)\[[ xX]\]", r"\1[ ]", line) lines.append(line) return "\n".join(lines).strip() + "\n" diff --git a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py index 9bc23e1..810749d 100644 --- a/packages/cli/src/templates/trellis/scripts/common/lifecycle.py +++ b/packages/cli/src/templates/trellis/scripts/common/lifecycle.py @@ -3,6 +3,7 @@ from __future__ import annotations from copy import deepcopy +import json from pathlib import Path from typing import Any @@ -23,6 +24,11 @@ ACTIVE_PACKAGE_STATUSES = {"running", "review", "blocked"} +ANCHOR_HISTORY_EVENTS = { + "plan_validated", + "plan_anchor_initialized", + "plan_drift_detected", +} def reconcile_task_lifecycle( @@ -134,13 +140,23 @@ def _reconcile_in_memory( and anchor["status"] == "missing" and phase_before_reconcile not in {"planning", "closed"} ): - data["plan_anchor"] = create_plan_anchor( - data, - task_dir, - origin="legacy_migration", - ) - issues.append("legacy plan anchor initialized") - anchor = plan_anchor_status(data, task_dir) + history_state = _anchor_history_state(task_dir) + if history_state == "none": + data["plan_anchor"] = create_plan_anchor( + data, + task_dir, + origin="legacy_migration", + ) + issues.append("legacy plan anchor initialized") + anchor = plan_anchor_status(data, task_dir) + else: + detail = ( + "plan anchor is missing after prior validation" + if history_state == "anchored" + else "plan anchor history is unreadable" + ) + _block_for_plan_integrity(data, current, detail) + issues.append(detail) elif ( initialize_legacy_anchor and anchor.get("upgrade_required") is True @@ -192,8 +208,19 @@ def _reconcile_in_memory( structural_change = True issues.append("inconsistent closed markers were reopened instead of auto-closing") elif all(closed_markers): - data["status"] = "completed" - data["current_work_package"] = None + if _has_task_closed_event(task_dir): + data["status"] = "completed" + data["current_work_package"] = None + else: + _block_for_plan_integrity( + data, + None, + "closed markers lack task_closed event", + phase="blocked", + next_action="Run closure.py audit and closure.py close to record the verified closure.", + blocker_prefix="closure integrity", + ) + issues.append("closed markers were reopened because task_closed event is missing") else: blocked_packages = [item for item in packages if item.get("status") == "blocked"] blockers = _string_list(data.get("blockers")) @@ -267,3 +294,64 @@ def _string_list(value: Any) -> list[str]: def _dedupe(values: list[str]) -> list[str]: return list(dict.fromkeys(values)) + + +def _anchor_history_state(task_dir: Path) -> str: + """Distinguish a pre-anchor task from a lost modern anchor.""" + path = task_dir / "hermes" / "task-events.jsonl" + if not path.exists(): + return "none" + try: + events = [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + except (OSError, json.JSONDecodeError): + return "unreadable" + return ( + "anchored" + if any( + isinstance(event, dict) + and event.get("event_type") in ANCHOR_HISTORY_EVENTS + for event in events + ) + else "none" + ) + + +def _has_task_closed_event(task_dir: Path) -> bool: + path = task_dir / "hermes" / "task-events.jsonl" + try: + return any( + isinstance(event, dict) + and event.get("event_type") == "task_closed" + and event.get("new_state") == "closed" + for event in ( + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + ) + except (OSError, json.JSONDecodeError): + return False + + +def _block_for_plan_integrity( + data: dict[str, Any], + current: dict[str, Any] | None, + detail: str, + *, + phase: str = "blocked", + next_action: str = "Restore the plan record or record an approved closure.py amend before continuing.", + blocker_prefix: str = "plan anchor drift", +) -> None: + blocker = f"{blocker_prefix}: {detail}" + if current is not None and current.get("status") not in DISPOSED_PACKAGE_STATUSES: + current["status"] = "blocked" + current["blocker"] = blocker + data["blockers"] = _dedupe([*_string_list(data.get("blockers")), blocker]) + data["closure_state"] = "open" + data["hermes_phase"] = phase + data["status"] = "in_progress" + data["next_action"] = next_action diff --git a/packages/cli/src/templates/trellis/scripts/common/task_store.py b/packages/cli/src/templates/trellis/scripts/common/task_store.py index d4e5c2b..1f719b3 100644 --- a/packages/cli/src/templates/trellis/scripts/common/task_store.py +++ b/packages/cli/src/templates/trellis/scripts/common/task_store.py @@ -18,6 +18,7 @@ import argparse import json import re +import shutil import sys from datetime import datetime from pathlib import Path @@ -412,6 +413,10 @@ def cmd_archive(args: argparse.Namespace) -> int: dir_name = task_dir.name task_json_path = task_dir / FILE_TASK_JSON + try: + session_task_ref = task_dir.relative_to(repo_root).as_posix() + except ValueError: + session_task_ref = str(task_dir) # Update status before archiving today = datetime.now().strftime("%Y-%m-%d") @@ -471,10 +476,6 @@ def cmd_archive(args: argparse.Namespace) -> int: write_json(child_json, child_data) modified_children.append(child_dir_path.name) - # Clear any session that still points at this task before the path moves. - from .active_task import clear_task_from_sessions - clear_task_from_sessions(str(task_dir), repo_root) - # Archive result = archive_task_complete(task_dir, repo_root) if "archived_to" in result: @@ -493,7 +494,36 @@ def cmd_archive(args: argparse.Namespace) -> int: reason="task.py archive", ) except ClosureError as exc: - print(colored(f"Warning: archived task event was not recorded: {exc}", Colors.YELLOW), file=sys.stderr) + try: + shutil.move(str(archive_dest), str(task_dir)) + except (OSError, shutil.Error) as rollback_exc: + print( + colored( + "Error: archive event was not recorded and archive rollback failed: " + f"{rollback_exc}", + Colors.RED, + ), + file=sys.stderr, + ) + else: + print( + colored( + "Error: archive event was not recorded; task was restored to its active location: " + f"{exc}", + Colors.RED, + ), + file=sys.stderr, + ) + return 1 + + # Only clear session pointers after the task and its archive record + # have both been persisted. A failed archive leaves the active task intact. + from .active_task import clear_task_from_sessions + clear_task_from_sessions(session_task_ref, repo_root) + # Existing session files may contain either the canonical relative + # path or the older short task name. Once the directory is archived, + # the latter can no longer resolve to its former active location. + clear_task_from_sessions(dir_name, repo_root) # Auto-commit unless --no-commit if not getattr(args, "no_commit", False): diff --git a/packages/cli/src/templates/trellis/workflow.md b/packages/cli/src/templates/trellis/workflow.md index 1a1c201..3e23a44 100644 --- a/packages/cli/src/templates/trellis/workflow.md +++ b/packages/cli/src/templates/trellis/workflow.md @@ -128,7 +128,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py run --task --job-id - Hermes 正式角色只保留 planner、researcher、coder、runner、reviewer;Profile 只表示角色内的当前工作模式。Agent Context Firewall 只正式支持 Claude Code 和 Codex:派发文件绑定 `hermes_revision`、当前 package 和最多 3 个引用;Claude Agent 只接收 `job_id`,Codex 使用同一派发和结果文件。原版 `trellis-implement` / `trellis-check` / `trellis-research` 继续使用原有兼容路径。 -阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案文档哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案文档或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 +阶段推进:planning 先 plan/validate;validate 会冻结当前目标、验收、研究协议和任务方案契约部分哈希为 plan anchor(计划锚点),并把完整锚点追加到事件历史;`Notes`(说明)、`Progress`(进度)和 `Experiment Log`(实验记录)可追加执行记录而不改变方案;ready 显示下一动作;running 只执行当前 package;review 先 package 检查或全局 audit;audit 有缺口时只允许有界 repair,不重新规划已完成 package;close 通过后才允许 finish/archive。执行中的锚点漂移只能通过经批准的 amend(计划变更)恢复到 planning,不能由聊天、运行成功、直接改方案正文或临时新方案覆盖。run finished、package done、task closed、claim approved 是四种不同状态,不能互相替代。 ### Workspace 系统 diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 1dd6d2e..3bbf9f8 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -537,10 +537,20 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); it("blocks direct changes to validated plan documents", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Original scope.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); expect(plan(root, ["Artifact exists"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); expect(run(root, "package-start").status).toBe(0); - fs.appendFileSync(path.join(taskDir, "prd.md"), "\nUnapproved scope change.\n"); + fs.writeFileSync( + prdPath, + fs.readFileSync(prdPath, "utf-8").replace("- Original scope.", "- Unapproved scope change."), + "utf-8", + ); const result = run(root, "package-check"); expect(result.status).toBe(1); @@ -549,6 +559,25 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).hermes_phase).toBe("blocked"); }); + it("allows execution notes without changing the plan anchor", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Fixed scope.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); + expect(plan(root, ["Artifact exists"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + expect(run(root, "package-start").status).toBe(0); + fs.appendFileSync( + prdPath, + "\n### Bounded run\n\nObserved during execution: the first bounded run completed.\n", + ); + + const result = run(root, "package-check"); + expect(result.status, result.stderr).toBe(0); + }); + it("blocks direct changes to the validated research contract", () => { expect(plan(root, ["Artifact exists"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); @@ -583,10 +612,20 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { }); it("records an approved plan-document amendment and preserves anchor history", () => { + const prdPath = path.join(taskDir, "prd.md"); + fs.writeFileSync( + prdPath, + "# PRD\n\n## Requirements\n\n- Original acceptance.\n\n## Notes\n\n- Execution notes.\n", + "utf-8", + ); expect(plan(root, ["Artifact exists"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); const before = readTask(taskDir) as TestTask & { plan_anchor: { hash: string } }; - fs.appendFileSync(path.join(taskDir, "prd.md"), "\nApproved acceptance clarification.\n"); + fs.writeFileSync( + prdPath, + fs.readFileSync(prdPath, "utf-8").replace("- Original acceptance.", "- Approved acceptance clarification."), + "utf-8", + ); const decisionRef = writeDecisionRecord(taskDir, "document-amendment.md"); const denied = run( @@ -1436,6 +1475,26 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(archivedEvents).toContain('"event_type":"task_archived"'); }); + it("restores a task when its archive event cannot be recorded", () => { + expect(plan(root, ["Lean result verified"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + completeCurrent(root, "archive-event-failure"); + expect(run(root, "close").status).toBe(0); + fs.chmodSync(path.join(taskDir, "hermes"), 0o500); + + const archive = runTask(root, "archive", "demo", "--no-commit"); + fs.chmodSync(path.join(taskDir, "hermes"), 0o700); + expect(archive.status).toBe(1); + expect(archive.stderr).toContain("task was restored to its active location"); + expect(fs.existsSync(taskDir)).toBe(true); + const archiveMonth = new Date().toISOString().slice(0, 7); + expect( + fs.existsSync( + path.join(root, ".trellis", "tasks", "archive", archiveMonth, "demo"), + ), + ).toBe(false); + }); + it("refuses archive when a closed task no longer matches its plan anchor", () => { expect(plan(root, ["Lean result verified"]).status).toBe(0); expect(run(root, "validate").status).toBe(0); diff --git a/packages/cli/test/scripts/lifecycle.integration.test.ts b/packages/cli/test/scripts/lifecycle.integration.test.ts index abc5b3a..3fa01b8 100644 --- a/packages/cli/test/scripts/lifecycle.integration.test.ts +++ b/packages/cli/test/scripts/lifecycle.integration.test.ts @@ -193,7 +193,7 @@ describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { const upgraded = readTask() as ReturnType & { plan_anchor: { schema: string; origin: string; projection: { plan_documents: unknown[] } }; }; - expect(upgraded.plan_anchor.schema).toBe("hermes-plan-anchor/v2"); + expect(upgraded.plan_anchor.schema).toBe("hermes-plan-anchor/v3"); expect(upgraded.plan_anchor.origin).toBe("schema_upgrade"); expect(upgraded.plan_anchor.projection.plan_documents).toBeDefined(); expect(upgraded.hermes_revision).toBe(1); @@ -201,12 +201,52 @@ describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { fs.readFileSync(path.join(taskDir, "hermes", "task-events.jsonl"), "utf-8").trim(), ) as { event_type: string; plan_anchor?: { schema?: string } }; expect(event.event_type).toBe("plan_anchor_initialized"); - expect(event.plan_anchor?.schema).toBe("hermes-plan-anchor/v2"); + expect(event.plan_anchor?.schema).toBe("hermes-plan-anchor/v3"); expect(reconcile().status).toBe(0); expect(readTask().hermes_revision).toBe(1); }); + it("upgrades a clean v2 full-document anchor without treating notes as drift", () => { + writeTask(task()); + fs.writeFileSync( + path.join(taskDir, "prd.md"), + "# PRD\n\n## Requirements\n\n- Fixed scope.\n\n## Notes\n\n- Execution detail.\n", + "utf-8", + ); + const legacy = spawnSync( + PYTHON, + [ + "-c", + [ + "import json, sys", + "from pathlib import Path", + "sys.path.insert(0, '.trellis/scripts')", + "from common.closure import create_plan_anchor, _plan_anchor_hash, _plan_document_hashes", + "path = Path('.trellis/tasks/demo/task.json')", + "data = json.loads(path.read_text(encoding='utf-8'))", + "anchor = create_plan_anchor(data, path.parent)", + "anchor['schema'] = 'hermes-plan-anchor/v2'", + "anchor['projection']['plan_documents'] = _plan_document_hashes(path.parent, legacy_full_text=True)", + "anchor['hash'] = _plan_anchor_hash(anchor['projection'])", + "data['plan_anchor'] = anchor", + "path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8')", + ].join("; "), + ], + { cwd: root, encoding: "utf-8" }, + ); + expect(legacy.status, legacy.stderr).toBe(0); + + expect(reconcile().status).toBe(0); + const upgraded = readTask() as ReturnType & { + plan_anchor: { schema: string; origin: string }; + }; + expect(upgraded.plan_anchor).toMatchObject({ + schema: "hermes-plan-anchor/v3", + origin: "schema_upgrade", + }); + }); + it("never accepts evidence, disposes a package, or closes a task", () => { writeTask(task({ hermes_phase: "review", @@ -227,6 +267,25 @@ describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { expect(current.status).toBe("in_progress"); }); + it("does not recreate a plan anchor that disappeared after validation", () => { + writeTask(task()); + expect(reconcile().status).toBe(0); + const anchored = readTask() as ReturnType & { + plan_anchor?: unknown; + }; + delete anchored.plan_anchor; + writeTask(anchored); + + expect(reconcile().status).toBe(0); + const current = readTask() as ReturnType & { + plan_anchor?: unknown; + }; + expect(current.plan_anchor).toBeUndefined(); + expect(current.hermes_phase).toBe("blocked"); + expect(current.status).toBe("in_progress"); + expect(current.blockers.join(" ")).toContain("missing after prior validation"); + }); + it("reopens inconsistent close markers instead of completing the task", () => { writeTask(task({ status: "completed", @@ -241,4 +300,21 @@ describe.skipIf(!hasPython())("Hermes task lifecycle reconciliation", () => { expect(current.hermes_phase).toBe("review"); expect(current.status).toBe("in_progress"); }); + + it("does not accept matching close markers without a close event", () => { + writeTask(task({ + status: "completed", + closure_state: "closed", + hermes_phase: "closed", + current_work_package: null, + work_packages: [{ ...task().work_packages[0], status: "done" }], + })); + + expect(reconcile().status).toBe(0); + const current = readTask(); + expect(current.closure_state).toBe("open"); + expect(current.hermes_phase).toBe("blocked"); + expect(current.status).toBe("in_progress"); + expect(current.blockers.join(" ")).toContain("task_closed event"); + }); }); diff --git a/packages/cli/test/templates/shared-hooks.test.ts b/packages/cli/test/templates/shared-hooks.test.ts index c442917..7fdaf06 100644 --- a/packages/cli/test/templates/shared-hooks.test.ts +++ b/packages/cli/test/templates/shared-hooks.test.ts @@ -1884,6 +1884,20 @@ describe.skipIf(!hasPython())("hermes runtime guard hook", () => { task.hermes_phase = "closed"; task.status = "completed"; fs.writeFileSync(taskPath, `${JSON.stringify(task)}\n`, "utf-8"); + const eventPath = path.join( + repoRoot, + ".trellis", + "tasks", + taskName, + "hermes", + "task-events.jsonl", + ); + fs.mkdirSync(path.dirname(eventPath), { recursive: true }); + fs.appendFileSync( + eventPath, + `${JSON.stringify({ event_type: "task_closed", new_state: "closed" })}\n`, + "utf-8", + ); } it("blocks Stop when an active task has no Hermes worker records", () => { From 97d5a92400cd6df7529525c94a97f4068a241280 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Sat, 25 Jul 2026 14:51:34 +0800 Subject: [PATCH 11/13] fix(closure): add compact approval visibility --- docs/manual/17-lean-research-closure.md | 13 ++ docs/manual/appendix-a-cli-reference.md | 3 + .../src/templates/trellis/scripts/closure.py | 32 +++- .../trellis/scripts/common/closure.py | 168 +++++++++++++++++- .../test/scripts/closure.integration.test.ts | 87 +++++++++ 5 files changed, 299 insertions(+), 4 deletions(-) diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index 3a9d06e..c099698 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -79,6 +79,17 @@ python3 ./.trellis/scripts/task.py archive "$TASK" --no-commit 模型架构、新假设、数据、切分、预处理、指标、基线或主张范围变化属于探索性研究;其中模型架构变化必须创建独立探索任务,不会覆盖旧任务结果。旧任务应明确保留为完成、延期、阻塞或失败,而不是把新路线写成原方案已经完成。 +## 状态可见性与回放 + +`status --json`(状态 JSON)会从最新未应用的 `plan_amended`(计划变更)事件显示待处理事项:变更字段、当前 `hermes_revision`(Hermes 修订号)、仍缺少的人工批准或决策记录,以及恢复条件。它不会把聊天中的“已同意”当作批准。 + +```bash +python3 ./.trellis/scripts/closure.py status --task "$TASK" --json +python3 ./.trellis/scripts/closure.py timeline --task "$TASK" +``` + +`timeline`(时间线)只汇总 `task-events.jsonl`(任务事件记录)和 `dispatch_audit.jsonl`(派发审计记录)的最近 12 条短摘要。它用于快速回看当前任务如何进入此状态,不读取原始终端日志、完整差异、搜索过程或聊天内容;`task.json`(任务状态)仍是当前事实源。 + ## Task Capsule `capsule`(紧凑上下文)默认控制在约 500–1000 个字符,包含任务目标、范围、模式、阶段、当前工作包、完成条件、下一动作、阻塞项和最多 3 个相关引用。 @@ -191,6 +202,8 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ 上下文采用渐进披露:首次进入任务或任务修订变化时,才注入不超过约 1000 字符的 `Task Capsule`(任务胶囊);后续请求只保留短续接提示。项目背景、研究方案、约束、任务详情和历史记录只提供文件路径,由主代理按当前请求读取直接相关的一个文件,不默认预读全部内容。 +在 `compact`(压缩)或 `clear`(清空)后,共享 hook 会再次注入当前 Task Capsule 和短续接提示。交接摘要只在修订号匹配时给出文件路径;其正文、完整需求文档和历史聊天不会自动重新注入,因此恢复时先以当前 `task.json`(任务状态)为准。 + ## 常见失败 - `validate` 提示缺少完成定义:用 `plan --done-when` 补充可观察验收条件。 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 098d804..dd68241 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -58,6 +58,7 @@ python3 ./.trellis/scripts/closure.py --help python3 ./.trellis/scripts/closure.py plan --task "$TASK" python3 ./.trellis/scripts/closure.py validate --task "$TASK" python3 ./.trellis/scripts/closure.py status --task "$TASK" +python3 ./.trellis/scripts/closure.py timeline --task "$TASK" python3 ./.trellis/scripts/closure.py next --task "$TASK" python3 ./.trellis/scripts/closure.py capsule --task "$TASK" python3 ./.trellis/scripts/closure.py package-start --task "$TASK" @@ -100,6 +101,8 @@ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id ``` +`status --json`(状态 JSON)会显示尚未满足的人工批准与决策记录条件。`timeline`(时间线)只读取任务事件和派发审计,默认显示最近 12 条短摘要,不显示原始日志、完整差异或聊天内容。 + `create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)在当前项目工作区使用紧凑派发协议;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 高风险入口包括 `research-trellis init --force`(强制初始化)、`research-trellis update --force`(强制更新)、`research-trellis uninstall --yes`(确认卸载)、`research-trellis channel rm`(删除频道)、高风险 `closure.py amend`(计划变更)和 `task.py archive`(归档任务,默认可能提交)。 diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index 7ec5e53..b26cbeb 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -18,13 +18,16 @@ block_package, build_capsule, check_package, + closure_timeline, closure_next_action, close_task, complete_package, + format_closure_timeline, format_audit_yaml, is_closure_task, mark_grill_complete, package_by_id, + pending_approval_summaries, plan_closure, repair_closure, resolve_closure_task, @@ -41,6 +44,16 @@ def add_common(parser: argparse.ArgumentParser) -> None: parser.add_argument("--actor", help="Actor written to task-events.jsonl") +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a positive integer") from exc + if parsed < 1 or parsed > 100: + raise argparse.ArgumentTypeError("must be between 1 and 100") + return parsed + + def package_spec(value: str) -> dict[str, Any]: """Parse TITLE::OUTCOME::DONE[|DONE]::EVIDENCE[|EVIDENCE].""" parts = value.split("::") @@ -92,6 +105,11 @@ def parser() -> argparse.ArgumentParser: add_common(status) status.add_argument("--json", action="store_true") + timeline = subparsers.add_parser("timeline", help="Show compact closure event timeline") + add_common(timeline) + timeline.add_argument("--limit", type=positive_int, default=12) + timeline.add_argument("--json", action="store_true") + next_command = subparsers.add_parser("next", help="Show the bounded next action") add_common(next_command) @@ -156,7 +174,7 @@ def main(argv: list[str] | None = None) -> int: task_dir, data, repo_root = resolve_closure_task(args.task) actor = actor_name(repo_root, args.actor) if not is_closure_task(data) and args.command != "plan": - if args.command in {"status", "next", "capsule"}: + if args.command in {"status", "next", "capsule", "timeline"}: print("Compatibility mode: legacy non-Hermes task; run closure.py plan to opt in.") return 0 raise ClosureError("legacy non-Hermes task; run closure.py plan before closure commands") @@ -224,10 +242,17 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "status": if args.json: - print(json.dumps(_status_record(data), ensure_ascii=False, indent=2)) + print(json.dumps(_status_record(task_dir, data), ensure_ascii=False, indent=2)) else: print(build_capsule(task_dir, data, repo_root)) return 0 + if args.command == "timeline": + timeline = closure_timeline(task_dir, data, limit=args.limit) + if args.json: + print(json.dumps(timeline, ensure_ascii=False, indent=2)) + else: + print(format_closure_timeline(timeline)) + return 0 if args.command == "next": print(closure_next_action(data)) return 0 @@ -317,7 +342,7 @@ def _json_or_text(value: str) -> Any: return value -def _status_record(data: dict[str, Any]) -> dict[str, Any]: +def _status_record(task_dir, data: dict[str, Any]) -> dict[str, Any]: current = data.get("current_work_package") current_package = package_by_id(data, str(current)) if current else None return { @@ -328,6 +353,7 @@ def _status_record(data: dict[str, Any]) -> dict[str, Any]: "current_work_package": current_package, "next_action": closure_next_action(data), "blockers": data.get("blockers") or [], + "pending_approvals": pending_approval_summaries(task_dir, data), "repair_count": data.get("repair_count", 0), "max_repair_count": data.get("max_repair_count", 1), } diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index ee704f1..9d653e3 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -2333,6 +2333,7 @@ def amend_plan( "affected_packages": affected_ids, "field": field, "approval_requirement": "human" if requires_human_approval else "none", + "decision_requirement": "required" if requires_decision_ref else "none", "approved_by": approved_by, "applied": applied, "research_change": research_change, @@ -2497,18 +2498,29 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None anchor = plan_anchor_status(data, task_dir) anchor_label = anchor["id"] or "unvalidated" anchor_state = anchor["status"] + pending_approvals = pending_approval_summaries(task_dir, data) lines = [ f"Task: {data.get('id') or task_dir.name} | {data.get('title') or task_dir.name}", f"Intent: {_clip(str(data.get('intent') or data.get('description') or ''), 180)}", f"Scope: {scope or '-'} | Out: {excluded or '-'}", f"Mode/Phase: {data.get('closure_mode', 'lean')} / {data.get('hermes_phase', 'planning')}", + ] + if pending_approvals: + pending = pending_approvals[0] + requirements = "+".join(pending["requirements"]) + lines.append( + "Pending approval: " + + f"{pending['field']} @ r{pending['task_revision']}; {requirements}; " + + _clip(pending["recovery_condition"], 180) + ) + lines.extend([ f"Plan anchor: {anchor_label} / {anchor_state}", "Definition: " + _compact_list(_string_list(data.get("definition_of_done")), 2, 220), research_route_summary(data), "Route rule: " + research_route_rule(data), f"Verify: {constraints['validation_level']} | Excluded platforms: {_compact_list(constraints['excluded_platforms'], 2) or '-'}", f"Current: {current_id or '-'}" + (f" - {_clip(str(current.get('outcome') or ''), 140)}" if current else ""), - ] + ]) if current: lines.append("Done when: " + _compact_list(_string_list(current.get("done_when")), 3, 240)) decision_ref = data.get("decision_ref") @@ -2523,6 +2535,160 @@ def build_capsule(task_dir: Path, data: dict[str, Any], root: Path | None = None return capsule if len(capsule) <= 1000 else capsule[:997].rstrip() + "..." +def pending_approval_summaries(task_dir: Path, data: dict[str, Any]) -> list[dict[str, Any]]: + """Return unresolved approval requirements from the latest amendment per field. + + The event log is historical evidence, while task.json remains authoritative. + Looking only at the latest amendment for each field prevents an old rejected + proposal from remaining visible after the same change was later approved. + """ + latest_by_field: dict[str, dict[str, Any]] = {} + for event in _read_jsonl_values(task_dir / "hermes" / "task-events.jsonl"): + if event.get("event_type") != "plan_amended": + continue + field = event.get("field") + if isinstance(field, str) and field.strip(): + latest_by_field[field] = event + + revision = data.get("hermes_revision", 0) + if not isinstance(revision, int) or isinstance(revision, bool): + revision = 0 + pending: list[dict[str, Any]] = [] + for event in latest_by_field.values(): + if event.get("applied") is not False: + continue + approval_required = event.get("approval_requirement") == "human" + approval_granted = str(event.get("approved_by") or "").casefold() in { + "human", + "root", + "human/root", + } + decision_required = event.get("decision_requirement") == "required" + if "decision_requirement" not in event: + field = str(event.get("field") or "") + decision_required = any( + f"requires --decision-ref: {field}" in str(blocker) + for blocker in _string_list(data.get("blockers")) + ) + decision_recorded = isinstance(event.get("decision_ref"), str) and bool( + str(event.get("decision_ref")).strip() + ) + needs_human = approval_required and not approval_granted + needs_decision = decision_required and not decision_recorded + if not needs_human and not needs_decision: + continue + requirements: list[str] = [] + if needs_decision: + requirements.append("decision record") + if needs_human: + requirements.append("human approval") + recovery = "Add " + " and ".join(requirements) + ", then rerun amend." + pending.append( + { + "field": str(event["field"]), + "task_revision": revision, + "requirements": requirements, + "requires_decision_record": needs_decision, + "requires_human_approval": needs_human, + "recovery_condition": recovery, + } + ) + return pending + + +def closure_timeline( + task_dir: Path, + data: dict[str, Any], + *, + limit: int = 12, +) -> dict[str, Any]: + """Return a short replay index without exposing raw logs or event payloads.""" + if limit < 1: + raise ClosureError("timeline limit must be at least 1") + entries: list[dict[str, Any]] = [] + event_labels = { + "plan_validated": "plan validated", + "plan_anchor_initialized": "plan anchor initialized", + "plan_drift_detected": "plan drift detected", + "phase_changed": "phase changed", + "package_started": "package started", + "package_completed": "package completed", + "package_blocked": "package blocked", + "plan_amended": "plan amendment recorded", + "research_route_updated": "research route updated", + "grill_completed": "exploration decision recorded", + "repair_started": "bounded repair started", + "task_closed": "task closed", + "task_archived": "task archived", + "lifecycle_reconciled": "lifecycle reconciled", + } + dispatch_labels = { + "dispatch_validated": "dispatch validated", + "dispatch_started": "dispatch started", + "result_confirmed": "result confirmed", + "result_rejected": "result rejected", + } + for event in _read_jsonl_values(task_dir / "hermes" / "task-events.jsonl"): + event_type = event.get("event_type") + timestamp = event.get("timestamp") + if not isinstance(event_type, str) or not isinstance(timestamp, str): + continue + summary = event_labels.get(event_type, "closure event recorded") + field = event.get("field") + if isinstance(field, str) and field.strip(): + summary += f": {field.strip()}" + entries.append( + { + "timestamp": timestamp, + "source": "task", + "event": event_type, + "package_id": event.get("package_id") if isinstance(event.get("package_id"), str) else None, + "summary": summary, + } + ) + for event in _read_jsonl_values(task_dir / "hermes" / "dispatch_audit.jsonl"): + event_name = event.get("event") + timestamp = event.get("timestamp") + if not isinstance(event_name, str) or not isinstance(timestamp, str): + continue + entries.append( + { + "timestamp": timestamp, + "source": "dispatch", + "event": event_name, + "job_id": event.get("job_id") if isinstance(event.get("job_id"), str) else None, + "summary": dispatch_labels.get(event_name, "dispatch audit recorded"), + } + ) + entries.sort(key=lambda item: (str(item["timestamp"]), str(item["source"]), str(item["event"]))) + revision = data.get("hermes_revision", 0) + return { + "task_id": data.get("id") or task_dir.name, + "hermes_revision": revision if isinstance(revision, int) and not isinstance(revision, bool) else 0, + "entries": entries[-min(limit, 100):], + } + + +def format_closure_timeline(value: dict[str, Any]) -> str: + lines = [ + f"Timeline: {value.get('task_id')} @ r{value.get('hermes_revision')}", + ] + entries = value.get("entries") if isinstance(value.get("entries"), list) else [] + if not entries: + lines.append("- no recorded closure events") + return "\n".join(lines) + for entry in entries: + if not isinstance(entry, dict): + continue + subject = entry.get("package_id") or entry.get("job_id") + suffix = f" [{subject}]" if subject else "" + lines.append( + f"- {entry.get('timestamp')} {entry.get('source')}:{entry.get('event')}{suffix}: " + f"{entry.get('summary')}" + ) + return "\n".join(lines) + + def write_handoff(task_dir: Path, data: dict[str, Any], root: Path | None = None) -> Path: revision = data.get("hermes_revision", 0) revision_text = str(revision) if isinstance(revision, int) and not isinstance(revision, bool) else "unknown" diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 3bbf9f8..3241415 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -1241,6 +1241,36 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(readTask(taskDir).meta.research_contract?.dataset).toBeUndefined(); expect(readTask(taskDir).hermes_phase).toBe("blocked"); + const pendingStatus = run(root, "status", "--json"); + expect(pendingStatus.status).toBe(0); + const pending = JSON.parse(pendingStatus.stdout).pending_approvals; + expect(pending).toEqual([ + expect.objectContaining({ + field: "dataset", + task_revision: readTask(taskDir).hermes_revision, + requires_decision_record: true, + requires_human_approval: true, + }), + ]); + expect(run(root, "capsule").stdout).toContain("Pending approval: dataset"); + const amendmentEvent = fs.readFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + "utf-8", + ); + expect(amendmentEvent).toContain('"decision_requirement":"required"'); + const legacyEvents = amendmentEvent.trim().split("\n").map((line) => JSON.parse(line)); + delete legacyEvents.at(-1).decision_requirement; + fs.writeFileSync( + path.join(taskDir, "hermes", "task-events.jsonl"), + `${legacyEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + ); + expect(JSON.parse(run(root, "status", "--json").stdout).pending_approvals).toEqual([ + expect.objectContaining({ + requires_decision_record: true, + requires_human_approval: true, + }), + ]); + const amendmentRef = writeDecisionRecord(taskDir, "dataset-amendment.md"); const approved = run( @@ -1265,6 +1295,7 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(task.research_route).toBe("exploration"); expect(task.research_change_fields).toEqual(["dataset"]); expect(task.grill_completed).toBe(false); + expect(JSON.parse(run(root, "status", "--json").stdout).pending_approvals).toEqual([]); expect(run(root, "validate").status).toBe(1); const decisionRef = writeDecisionRecord(taskDir); const grilled = run(root, "grill", "--complete", "--decision-ref", decisionRef); @@ -1272,6 +1303,62 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(run(root, "validate").status).toBe(0); }); + it("shows a compact timeline without raw dispatch audit payloads", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + fs.writeFileSync( + path.join(taskDir, "hermes", "dispatch_audit.jsonl"), + `${JSON.stringify({ + type: "dispatch_audit", + timestamp: "2026-07-25T12:00:00+00:00", + job_id: "job-7", + event: "result_confirmed", + decision: "RAW_DECISION_MUST_NOT_APPEAR\n+++ a/dispatch.log", + metrics: { raw_log: "DISPATCH_LOG_MUST_NOT_APPEAR" }, + })}\n`, + ); + const result = run(root, "timeline", "--json", "--limit", "2"); + expect(result.status, result.stderr).toBe(0); + const timeline = JSON.parse(result.stdout); + expect(timeline.entries).toHaveLength(2); + expect(timeline.entries).toContainEqual(expect.objectContaining({ + source: "dispatch", + event: "result_confirmed", + job_id: "job-7", + summary: "result confirmed", + })); + expect(result.stdout).not.toContain("DISPATCH_LOG_MUST_NOT_APPEAR"); + expect(result.stdout).not.toContain("RAW_DECISION_MUST_NOT_APPEAR"); + expect(result.stdout.length).toBeLessThan(1600); + }); + + it("does not expose amendment reasons or accept an invalid timeline limit", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const before = readTask(taskDir); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const beforeEvents = fs.readFileSync(eventPath, "utf-8"); + const rejected = run(root, "timeline", "--limit", "0"); + expect(rejected.status).toBe(2); + expect(readTask(taskDir)).toEqual(before); + expect(fs.readFileSync(eventPath, "utf-8")).toBe(beforeEvents); + + fs.appendFileSync( + eventPath, + `${JSON.stringify({ + event_type: "plan_amended", + timestamp: "2026-07-25T12:01:00+00:00", + field: "dataset", + reason: "RAW_DIFF_MUST_NOT_APPEAR\n+++ a/secret.log\nFULL_LOG_MUST_NOT_APPEAR", + })}\n`, + ); + const timeline = run(root, "timeline", "--json"); + expect(timeline.status, timeline.stderr).toBe(0); + expect(timeline.stdout).toContain("plan amendment recorded: dataset"); + expect(timeline.stdout).not.toContain("RAW_DIFF_MUST_NOT_APPEAR"); + expect(timeline.stdout).not.toContain("FULL_LOG_MUST_NOT_APPEAR"); + }); + it("preserves the completed decision until a research amendment is applied", () => { expect( plan(root, ["Research result is verified"], [ From 3ad37e2522966888b49e1586b60d8f7d695d779b Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Sat, 25 Jul 2026 15:11:16 +0800 Subject: [PATCH 12/13] fix(closure): keep timeline reads bounded --- docs/manual/17-lean-research-closure.md | 2 +- docs/manual/appendix-a-cli-reference.md | 2 +- .../src/templates/trellis/scripts/closure.py | 2 +- .../trellis/scripts/common/closure.py | 55 ++++++++++++++++++- .../test/scripts/closure.integration.test.ts | 39 +++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index c099698..a7952c9 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/closure.py status --task "$TASK" --json python3 ./.trellis/scripts/closure.py timeline --task "$TASK" ``` -`timeline`(时间线)只汇总 `task-events.jsonl`(任务事件记录)和 `dispatch_audit.jsonl`(派发审计记录)的最近 12 条短摘要。它用于快速回看当前任务如何进入此状态,不读取原始终端日志、完整差异、搜索过程或聊天内容;`task.json`(任务状态)仍是当前事实源。 +`timeline`(时间线)只汇总 `task-events.jsonl`(任务事件记录)和 `dispatch_audit.jsonl`(派发审计记录)末尾的最近 12 条短摘要。它不运行生命周期校正、不修改 `task.json`(任务状态)或事件记录;用于快速回看当前任务如何进入此状态,不读取原始终端日志、完整差异、搜索过程或聊天内容。 ## Task Capsule diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index dd68241..05c0507 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -101,7 +101,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py list --task "$TASK" python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id ``` -`status --json`(状态 JSON)会显示尚未满足的人工批准与决策记录条件。`timeline`(时间线)只读取任务事件和派发审计,默认显示最近 12 条短摘要,不显示原始日志、完整差异或聊天内容。 +`status --json`(状态 JSON)会显示尚未满足的人工批准与决策记录条件。`timeline`(时间线)只从任务事件和派发审计末尾读取默认最近 12 条短摘要,不修改任务状态,也不显示原始日志、完整差异或聊天内容。 `create`(创建)会同时生成派发文件和任务卡;`validate`(校验)检查修订号与边界;`show`(显示)默认不读取 raw trace(原始跟踪);`run`(运行)在当前项目工作区使用紧凑派发协议;`apply`(应用)保存原始结果后只写入净化结果并更新 `next_action`(下一动作)。 diff --git a/packages/cli/src/templates/trellis/scripts/closure.py b/packages/cli/src/templates/trellis/scripts/closure.py index b26cbeb..6ce3791 100644 --- a/packages/cli/src/templates/trellis/scripts/closure.py +++ b/packages/cli/src/templates/trellis/scripts/closure.py @@ -178,7 +178,7 @@ def main(argv: list[str] | None = None) -> int: print("Compatibility mode: legacy non-Hermes task; run closure.py plan to opt in.") return 0 raise ClosureError("legacy non-Hermes task; run closure.py plan before closure commands") - if is_closure_task(data) and args.command != "plan": + if is_closure_task(data) and args.command not in {"plan", "timeline"}: reconcile_task_lifecycle( task_dir, data, diff --git a/packages/cli/src/templates/trellis/scripts/common/closure.py b/packages/cli/src/templates/trellis/scripts/common/closure.py index 9d653e3..ccbfad8 100644 --- a/packages/cli/src/templates/trellis/scripts/common/closure.py +++ b/packages/cli/src/templates/trellis/scripts/common/closure.py @@ -2628,7 +2628,10 @@ def closure_timeline( "result_confirmed": "result confirmed", "result_rejected": "result rejected", } - for event in _read_jsonl_values(task_dir / "hermes" / "task-events.jsonl"): + for event in _read_jsonl_tail_values( + task_dir / "hermes" / "task-events.jsonl", + limit, + ): event_type = event.get("event_type") timestamp = event.get("timestamp") if not isinstance(event_type, str) or not isinstance(timestamp, str): @@ -2646,7 +2649,10 @@ def closure_timeline( "summary": summary, } ) - for event in _read_jsonl_values(task_dir / "hermes" / "dispatch_audit.jsonl"): + for event in _read_jsonl_tail_values( + task_dir / "hermes" / "dispatch_audit.jsonl", + limit, + ): event_name = event.get("event") timestamp = event.get("timestamp") if not isinstance(event_name, str) or not isinstance(timestamp, str): @@ -3309,6 +3315,51 @@ def _read_jsonl_values(path: Path) -> list[dict[str, Any]]: return values +def _read_jsonl_tail_values(path: Path, limit: int) -> list[dict[str, Any]]: + """Read only the newest valid JSONL records needed for a compact view.""" + if limit < 1 or not path.is_file(): + return [] + values: list[dict[str, Any]] = [] + try: + with path.open("rb") as handle: + handle.seek(0, 2) + position = handle.tell() + partial = b"" + while position > 0 and len(values) < limit: + chunk_size = min(8192, position) + position -= chunk_size + handle.seek(position) + partial = handle.read(chunk_size) + partial + lines = partial.split(b"\n") + partial = lines[0] + for line in reversed(lines[1:]): + value = _jsonl_line_value(line) + if value is not None: + values.append(value) + if len(values) >= limit: + break + if len(values) < limit: + value = _jsonl_line_value(partial) + if value is not None: + values.append(value) + except OSError: + return [] + values.reverse() + return values + + +def _jsonl_line_value(line: bytes) -> dict[str, Any] | None: + if not line.strip(): + return None + try: + value = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(value, dict) or "_example" in value: + return None + return value + + def _valid_artifact_hash(value: Any) -> bool: if not isinstance(value, str): return False diff --git a/packages/cli/test/scripts/closure.integration.test.ts b/packages/cli/test/scripts/closure.integration.test.ts index 3241415..55184d1 100644 --- a/packages/cli/test/scripts/closure.integration.test.ts +++ b/packages/cli/test/scripts/closure.integration.test.ts @@ -1359,6 +1359,45 @@ describe.skipIf(!hasPython())("Lean Research Closure CLI", () => { expect(timeline.stdout).not.toContain("FULL_LOG_MUST_NOT_APPEAR"); }); + it("keeps a valid timeline read-only when lifecycle state is repairable", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + expect(run(root, "validate").status).toBe(0); + const inconsistent = readTask(taskDir); + inconsistent.current_work_package = "missing-package"; + writeTask(taskDir, inconsistent); + const beforeTask = fs.readFileSync(path.join(taskDir, "task.json"), "utf-8"); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const beforeEvents = fs.readFileSync(eventPath, "utf-8"); + + const result = run(root, "timeline", "--json"); + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(path.join(taskDir, "task.json"), "utf-8")).toBe(beforeTask); + expect(fs.readFileSync(eventPath, "utf-8")).toBe(beforeEvents); + }); + + it("bounds timeline output to recent records from long event history", () => { + expect(plan(root, ["Result A"]).status).toBe(0); + const eventPath = path.join(taskDir, "hermes", "task-events.jsonl"); + const history = Array.from({ length: 160 }, (_, index) => { + const hour = String(Math.floor(index / 60)).padStart(2, "0"); + const minute = String(index % 60).padStart(2, "0"); + return JSON.stringify({ + event_type: "phase_changed", + timestamp: `2026-07-25T${hour}:${minute}:00+00:00`, + package_id: `WP${index}`, + reason: `old event ${index}`, + }); + }); + fs.writeFileSync(eventPath, `${history.join("\n")}\n`); + + const result = run(root, "timeline", "--json", "--limit", "3"); + expect(result.status, result.stderr).toBe(0); + const timeline = JSON.parse(result.stdout); + expect(timeline.entries).toHaveLength(3); + expect(timeline.entries.map((entry: { package_id: string }) => entry.package_id)) + .toEqual(["WP157", "WP158", "WP159"]); + }); + it("preserves the completed decision until a research amendment is applied", () => { expect( plan(root, ["Research result is verified"], [ From f7c24d2f0405cf1c66db63a54e6a41d2efe1c134 Mon Sep 17 00:00:00 2001 From: Explero <502024240091@smail.nju.edu.cn> Date: Sat, 25 Jul 2026 17:00:29 +0800 Subject: [PATCH 13/13] chore(release): prepare 0.7.1-beta.2 --- README.md | 4 ++-- docs/manual/01-overview.md | 4 ++-- docs/manual/02-installation.md | 12 ++++++------ docs/manual/03-first-research-task.md | 2 +- docs/manual/04-how-it-works.md | 2 +- docs/manual/05-project-setup.md | 2 +- docs/manual/06-tasks-and-planning.md | 2 +- docs/manual/07-spec-and-context.md | 2 +- docs/manual/08-hermes-records.md | 2 +- docs/manual/09-experiments.md | 2 +- docs/manual/10-runs-and-replay.md | 2 +- docs/manual/11-metrics-and-comparison.md | 2 +- docs/manual/12-reports-and-claims.md | 2 +- docs/manual/13-workers-and-gates.md | 2 +- docs/manual/14-workflows-and-platforms.md | 4 ++-- docs/manual/15-channel-and-memory.md | 2 +- docs/manual/16-update-recovery-and-uninstall.md | 2 +- docs/manual/17-lean-research-closure.md | 2 +- docs/manual/README.md | 2 +- docs/manual/appendix-a-cli-reference.md | 4 ++-- docs/manual/appendix-b-directory-reference.md | 2 +- docs/manual/appendix-c-configuration-reference.md | 2 +- docs/manual/appendix-d-record-schema.md | 2 +- docs/manual/appendix-e-troubleshooting.md | 2 +- docs/manual/appendix-f-feature-status.md | 4 ++-- docs/manual/sources.md | 2 +- packages/cli/README.md | 4 ++-- packages/cli/package.json | 2 +- .../cli/src/migrations/manifests/0.7.1-beta.1.json | 9 +++++++++ .../cli/src/migrations/manifests/0.7.1-beta.2.json | 9 +++++++++ .../cli/src/templates/trellis/hermes/config.yaml | 2 +- packages/core/README.md | 4 ++-- packages/core/package.json | 2 +- 33 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/migrations/manifests/0.7.1-beta.1.json create mode 100644 packages/cli/src/migrations/manifests/0.7.1-beta.2.json diff --git a/README.md b/README.md index 71ac9f6..f4320cb 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ node packages/cli/scripts/release-preflight.js verify-packed-cli 新包发布后,可固定安装本次测试版: ```bash -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -108,7 +108,7 @@ research-trellis --version ```bash npm uninstall -g trellis-hermes -npm install -g research-trellis@0.7.1-beta.1 +npm install -g research-trellis@0.7.1-beta.2 research-trellis --version ``` diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md index 2d075b3..faab496 100644 --- a/docs/manual/01-overview.md +++ b/docs/manual/01-overview.md @@ -11,7 +11,7 @@ ## 前置条件 - 能管理目标项目中的文件和提交。 -- 接受当前版本为 `0.7.1-beta.1`(测试版)。 +- 接受当前版本为 `0.7.1-beta.2`(测试版)。 - 不把它当作安全隔离工具或自动科研结论系统。 ## 操作步骤 @@ -45,7 +45,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "AGPL|Hermes|未实现" README.md docs/manual/appendix-f-feature-status.md`(静态核对)。 - 结果:项目定位、上游归属、实验性与未实现边界已对齐。 diff --git a/docs/manual/02-installation.md b/docs/manual/02-installation.md index 07210f7..a1a0e99 100644 --- a/docs/manual/02-installation.md +++ b/docs/manual/02-installation.md @@ -28,7 +28,7 @@ git --version 新包发布后,推荐固定安装本手册对应的测试版: ```bash -npm install --global research-trellis@0.7.1-beta.1 +npm install --global research-trellis@0.7.1-beta.2 research-trellis --version research-trellis --help ``` @@ -36,14 +36,14 @@ research-trellis --help 如不希望全局安装,可临时执行: ```bash -npm exec --yes --package research-trellis@0.7.1-beta.1 -- research-trellis --version +npm exec --yes --package research-trellis@0.7.1-beta.2 -- research-trellis --version ``` 已经安装旧包时,先卸载旧名称,再安装新包: ```bash npm uninstall --global trellis-hermes -npm install --global research-trellis@0.7.1-beta.1 +npm install --global research-trellis@0.7.1-beta.2 research-trellis --version ``` @@ -62,7 +62,7 @@ node packages/cli/bin/trellis.js --version ## 预期结果 -版本命令输出 `0.7.1-beta.1`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 +版本命令输出 `0.7.1-beta.2`,帮助中能看到 `init`(初始化)、`update`(更新)、`workflow`(工作流)、`channel`(协作频道)和 `mem`(会话记忆)等命令。 ## 失败恢复 @@ -74,10 +74,10 @@ node packages/cli/bin/trellis.js --version ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`node -p "require('./packages/cli/package.json').version"`(包版本核对)。 -- 结果:源码包版本为 `0.7.1-beta.1`(测试版),运行时要求与包配置一致。 +- 结果:源码包版本为 `0.7.1-beta.2`(测试版),运行时要求与包配置一致。 - 未验证项:本轮未重新进行全局安装,也未实时查询 `npm`(Node 包平台)标签。 ## 来源 diff --git a/docs/manual/03-first-research-task.md b/docs/manual/03-first-research-task.md index a12948e..f3d21ff 100644 --- a/docs/manual/03-first-research-task.md +++ b/docs/manual/03-first-research-task.md @@ -95,7 +95,7 @@ python3 ./.trellis/scripts/closure.py close --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "experiment.py|task.py start|--codex" packages/cli/src packages/cli/test`(实现与测试核对)。 - 结果:初始化、任务创建、实验校验和启动步骤与当前入口一致。 diff --git a/docs/manual/04-how-it-works.md b/docs/manual/04-how-it-works.md index b427ed7..23b6175 100644 --- a/docs/manual/04-how-it-works.md +++ b/docs/manual/04-how-it-works.md @@ -62,7 +62,7 @@ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "state_machine|approval-gate|task.py" packages/cli/src/templates`(协议与实现核对)。 - 结果:任务生命周期有实现支持;科研状态文件已准确标为协议模板。 diff --git a/docs/manual/05-project-setup.md b/docs/manual/05-project-setup.md index 8ec97d2..dc4c41f 100644 --- a/docs/manual/05-project-setup.md +++ b/docs/manual/05-project-setup.md @@ -88,7 +88,7 @@ python3 ./.trellis/scripts/task.py list ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "options.yes|registry|selectedTemplate" packages/cli/src/commands/init.ts packages/cli/test/commands/init.integration.test.ts`(分支核对)。 - 结果:默认、交互、指定模板、自定义源和 `-y`(自动确认)的失败行为已分开记录。 diff --git a/docs/manual/06-tasks-and-planning.md b/docs/manual/06-tasks-and-planning.md index d90a30e..410f53a 100644 --- a/docs/manual/06-tasks-and-planning.md +++ b/docs/manual/06-tasks-and-planning.md @@ -104,7 +104,7 @@ python3 ./.trellis/scripts/closure.py grill --task "$TASK" --complete \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "plan_change|decision_state|supersedes" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(变更协议核对)。 - 结果:提出、审阅、接受或拒绝、替代和校验流程已补全,并明示了结构校验边界。 diff --git a/docs/manual/07-spec-and-context.md b/docs/manual/07-spec-and-context.md index be8aede..d560489 100644 --- a/docs/manual/07-spec-and-context.md +++ b/docs/manual/07-spec-and-context.md @@ -62,7 +62,7 @@ python3 ./.trellis/scripts/task.py validate "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "implement.jsonl|check.jsonl|context" packages/cli/src/templates/trellis/scripts packages/cli/test`(上下文实现核对)。 - 结果:上下文清单、重复检查和平台读取路径与当前模板一致。 diff --git a/docs/manual/08-hermes-records.md b/docs/manual/08-hermes-records.md index 835c282..3eed7ec 100644 --- a/docs/manual/08-hermes-records.md +++ b/docs/manual/08-hermes-records.md @@ -72,7 +72,7 @@ python3 ./.trellis/scripts/hermes/validate.py --task "$TASK" --kind evidence ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n "validate_artifact_file|artifact_ref_errors|has_symlink_component" packages/cli/src/templates/trellis/scripts/hermes/runtime.py`(路径与引用边界核对)。 - 结果:示例已改为先创建产物和摘要,再写产物与证据账本;路径检查范围已明示。 diff --git a/docs/manual/09-experiments.md b/docs/manual/09-experiments.md index 239b7dd..0cc11a7 100644 --- a/docs/manual/09-experiments.md +++ b/docs/manual/09-experiments.md @@ -78,7 +78,7 @@ python3 ./.trellis/scripts/hermes/experiment.py validate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "allowed_commands" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(实现与测试核对)。 - 结果:命令允许列表和本地运行记录均可定位。 diff --git a/docs/manual/10-runs-and-replay.md b/docs/manual/10-runs-and-replay.md index 47c0a38..51adae6 100644 --- a/docs/manual/10-runs-and-replay.md +++ b/docs/manual/10-runs-and-replay.md @@ -65,7 +65,7 @@ python3 ./.trellis/scripts/hermes/runner.py replay --task "$TASK" --run-id "$RUN ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "run_manifest|replay" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(运行清单与复查核对)。 - 结果:运行清单、输出摘要和复查失败分支均有实现与测试依据。 diff --git a/docs/manual/11-metrics-and-comparison.md b/docs/manual/11-metrics-and-comparison.md index ba241cb..238fb13 100644 --- a/docs/manual/11-metrics-and-comparison.md +++ b/docs/manual/11-metrics-and-comparison.md @@ -77,7 +77,7 @@ python3 ./.trellis/scripts/hermes/report.py quality-gate --task "$TASK" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "quality-gate|evidence_refs|claim_refs" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(门禁和引用核对)。 - 结果:质量门禁已有账本编号存在性检查以及有效、缺失引用测试。 diff --git a/docs/manual/12-reports-and-claims.md b/docs/manual/12-reports-and-claims.md index f8a9529..f72d30a 100644 --- a/docs/manual/12-reports-and-claims.md +++ b/docs/manual/12-reports-and-claims.md @@ -69,7 +69,7 @@ python3 ./.trellis/scripts/hermes/report.py approval-gate --task "$TASK" --claim ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "claim-review|approval-gate" packages/cli/src/templates/trellis/scripts/hermes packages/cli/test/templates/hermes-runtime.test.ts`(主张与审批边界核对)。 - 结果:主张审查和审批门禁入口可定位,报告与人工批准保持分离。 diff --git a/docs/manual/13-workers-and-gates.md b/docs/manual/13-workers-and-gates.md index 5834bdd..d080a3f 100644 --- a/docs/manual/13-workers-and-gates.md +++ b/docs/manual/13-workers-and-gates.md @@ -179,7 +179,7 @@ python3 ./.trellis/scripts/hermes/evidence.py summary --task "$TASK" ## 验证记录 - 日期:2026-07-16。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "PreToolUse|Stop" packages/cli/src/templates/{claude,codex,shared-hooks} packages/cli/test`(钩子与门禁核对)。 - 结果:五个正式角色、模式校验、旧角色迁移、两平台模板、最小上下文、证据工具和模式感知门禁均有自动化覆盖。 diff --git a/docs/manual/14-workflows-and-platforms.md b/docs/manual/14-workflows-and-platforms.md index e75ff6f..947019a 100644 --- a/docs/manual/14-workflows-and-platforms.md +++ b/docs/manual/14-workflows-and-platforms.md @@ -71,7 +71,7 @@ trust_level = "trusted" `codex.dispatch_mode: inline`(Codex 主会话路由)是显式兼容模式,只改变普通 Trellis 提示路由,不会绕过已启用的 Hermes 写入和结束门禁,也不会提供操作系统隔离。 -`v0.7.1-beta.1`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 +`v0.7.1-beta.2`(当前测试版)的 closure 任务仍可在各平台读取 `Task Capsule`(紧凑任务上下文),但可执行的 `Agent Context Firewall`(代理上下文防火墙)只正式支持 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)。其他平台保留原有 Trellis 上下文和代理行为,不宣称具备派发替换、结构化返回或状态确认门禁。 五个正式 `Hermes`(科研工作流)角色模板目前只为 `Claude Code`(Claude 代码工具)和 `Codex`(代码代理平台)提供。`Claude Code`(Claude 代码工具)可使用已批准的 `PreToolUse / SubagentStop / PostToolUse`(工具前、子代理结束、工具后)钩子完成派发替换和结果净化;`Codex`(代码代理平台)使用相同的紧凑派发与结果契约,并通过项目钩子限制主代理写入、直接执行和提前结束。Codex 的子代理输入仍依赖协议,不宣称完全硬隔离。 @@ -124,7 +124,7 @@ trust_level = "trusted" ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dispatch_mode|agentCapable|hasHooks" packages/cli/src packages/cli/test`(平台能力核对)。 - 结果:常规平台能力表保留;代理上下文防火墙的正式范围收窄为 Claude Code 和 Codex,并使用相同的最小上下文语义。 diff --git a/docs/manual/15-channel-and-memory.md b/docs/manual/15-channel-and-memory.md index ee450f2..8638011 100644 --- a/docs/manual/15-channel-and-memory.md +++ b/docs/manual/15-channel-and-memory.md @@ -66,7 +66,7 @@ research-trellis mem search "固定指标" --platform codex --cwd "$PWD" --limit ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "OpenCode|channel|mem" packages/cli/src/commands packages/core/src packages/cli/test packages/core/test`(频道与会话读取核对)。 - 结果:频道、会话读取和 `OpenCode`(开放代码工具)未实现分支均有当前代码与测试依据。 diff --git a/docs/manual/16-update-recovery-and-uninstall.md b/docs/manual/16-update-recovery-and-uninstall.md index bdce79a..0e80a97 100644 --- a/docs/manual/16-update-recovery-and-uninstall.md +++ b/docs/manual/16-update-recovery-and-uninstall.md @@ -69,7 +69,7 @@ research-trellis uninstall --yes ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 更名前基准提交:`9f7dc8497b4782878d6fa7ac3b63eba5bde507df`。 - 命令:`rg -n -m 1 "dry-run|create-new|allow-downgrade" packages/cli/src/commands packages/cli/test/commands`(更新与恢复核对)。 - 结果:预览、冲突副本、降级拒绝和卸载预览分支均可定位。 diff --git a/docs/manual/17-lean-research-closure.md b/docs/manual/17-lean-research-closure.md index a7952c9..e8cebf7 100644 --- a/docs/manual/17-lean-research-closure.md +++ b/docs/manual/17-lean-research-closure.md @@ -229,7 +229,7 @@ python3 ./.trellis/scripts/closure.py amend --task "$TASK" \ ## 验证记录 - 日期:2026-07-15。 -- 版本:`0.7.1-beta.1`(测试版)。 +- 版本:`0.7.1-beta.2`(测试版)。 - 命令:closure CLI 集成测试、共享 hook 测试、核心 task schema 测试和场景 smoke test(冒烟测试)。 - 结果:适度拆解、修订号、派发过期、结果净化、模式心跳、有限 repair、关闭和归档兼容均有自动化覆盖。 - 未验证项:自动候选工作包的科研语义仍需任务负责人审阅;人工审批身份真实性由外部流程保证。 diff --git a/docs/manual/README.md b/docs/manual/README.md index 50083ea..ac924c1 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,6 +1,6 @@ # Research Trellis 中文手册 -这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.1`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 +这套手册面向第一次把 `Research Trellis`(科研改造版)放进真实科研项目的人。当前仓库版本为 `0.7.1-beta.2`(测试版);它基于 `mindfold-ai/Trellis`(上游项目)修改,不是上游官方版本,也不代表上游立场。 ## 最短上手路径 diff --git a/docs/manual/appendix-a-cli-reference.md b/docs/manual/appendix-a-cli-reference.md index 05c0507..9233a33 100644 --- a/docs/manual/appendix-a-cli-reference.md +++ b/docs/manual/appendix-a-cli-reference.md @@ -6,7 +6,7 @@ ## 适用范围 -适用于 `0.7.1-beta.1`(测试版)。 +适用于 `0.7.1-beta.2`(测试版)。 ## 前置条件 @@ -118,7 +118,7 @@ python3 ./.trellis/scripts/hermes/dispatch.py status --task "$TASK" --job-id