diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 34ea3c79..18112196 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -11,6 +11,32 @@ TaskLifecycle 规范能力吸纳、源码操作状态恢复及本次入口/CP/ 修复和边界见 [v1.20.5候选说明](releases/v1.20.5.md)。当前仅准备本地候选,不表示已发布或最终资格通过。下方既有历史条目保留,不批量移入新版本。 +## Grok Bot 宿主驱动(grok-bot) + +- 会话隔离:Grok Bot 多会话共用连接器与 `grok-bot-session` 时会互相覆盖同一项目的 lifecycle-state。现驱动 `turn-begin`/`observe-tool` 支持 `--session-id`,连接器每次调用可携带 `hostSession`(校验格式与 contextBinding 归属,逐调用切换会话);仅对 grok-bot 驱动宿主,lifecycle/恢复/准入/路由状态按 `.memory/hooks//.sessions//` 分区,其他宿主不变。新增 `hooks/_runtime/host-session-partition.cjs` 与双会话交错/任务恢复不越界测试。 +- 新增 `grok-bot` hostVariant(`grok-bot/driver-synthesized-local-stdio`,UNVERIFIED、非默认):`host-adapter-identity.cjs` 注册变体/别名,`DEVCODEX_AGENT=grok-bot` 归一为 `grok` agent,不再落入 `unknown-agent`。 +- `lifecycle-host-adapters.cjs grok-bot` 复用 Grok 事件映射,事件与输出均标记 `devcodexEventProvenance=driver-synthesized`,不冒充原生宿主事件。 +- 新增 `devcodex grok-bot turn-begin|observe-tool|run`(`scripts/lib/grok-bot-driver.js`),供无原生生命周期 Hook 的 Grok Bot 合成 SessionStart/UserPromptSubmit 并为每次 MCP 调用补发 Pre/PostToolUse,打通 profile_context_plan → skill_route → memory_task_admit_v2。 +- `observe-tool` 新增 `--args-file`、`--response-file`(自动剥离 Grok Bot 不可信数据围栏并还原 MCP CallToolResult 形状)并强制 `--project-dir`;每次 MCP 调用前后各一条命令。 +- 入口检查感知 grok-bot:`profile_compose_entry_check` 的 PC5 在 `DEVCODEX_AGENT=grok-bot` 时如实报告 Grok Bot 驱动档(`WARN`,Partial 类,不可判 Full);PC6 读取项目 Git 分支与未提交改动数(有改动为 `WARN`);PC4 读取 Profile `config.mode`,dev 模式在调用方提供 `entry.specRadar` 时判 `PASS`。其他宿主无证据时仍为 `UNVERIFIED`。 +- `memory_task_admit_v2` 回执新增 `ingressWarnings`:调用方传入的 ingressRef 无效而回退到服务端入口投影时不再静默。 +- `observe-tool --tool` 仅为裸 DevCodex 工具名补命名空间,原生工具名(Write/Edit/Bash)原样透传。 +- Grok 插件 Skill 增加「Grok Bot (no native hooks)」小节与 HostParity 驱动档行。 +- 新增 `scripts/grok-bot-runtime.sh update|restart|status`:Grok Bot 连接器与驱动固定运行在独立干净副本(默认 `/workspace/devcodex-runtime`),只做 fast-forward 更新,并按副本路径重启 profile/memory 服务。 +- 新增来源级别 `driver-synthesized-host-event`:驱动合成事件不再记为原生 `trusted-host-event`;仅 `grok-bot/driver-synthesized-local-stdio` 变体可获得等同的入口授权(语义决策、准入、恢复判定),其他宿主不变,伪造到其他变体会被校验拒绝。 +- 新增 `memory_turn_closeout_v1`:一次调用完成 S05——可选新建报告(与 operational 写租约同等授权校验,仅限 `reports/` 下新文件)、会话分配/复用、📨 对话行与状态行更新、SUMMARY 追加。 +- `memory_session_write` 新增 `turn`(自动编号写入 📨 表格行)与 `sessionStatus`(更新「状态」行,不再停留在「已分配,待补充内容」);自由 Markdown 紧跟表格时自动空行分隔,不再拼进表格。`content` 改为可选(至少提供 content/turn/sessionStatus/artifacts 之一)。 +- 入口检查表 PC 标签与内容对齐(PC0 版本与运行、PC2 会话、PC3 执行准备、PC5 宿主、PC6 工作区、PC7 续接、PC8 流程与方案;英文同步),模板 `precheck-status.prompt.md` 同步;`profile_compose_entry_check` 未提供 `languageDecision` 时按用户提示语言渲染(中文提示 → 中文)。 +- `devcodex grok-bot turn-begin` 输出 `profileFreshness` 与 `warnings`:Profile current-truth `gitHead` 与项目 HEAD 不一致(`PROFILE_TRUTH_DRIFT`)或 Profile 更新后已有 ≥20 次提交(`PROFILE_POSSIBLY_STALE`)时告警。 +- Grok 插件 Skill 的 Grok Bot 小节补充:S04 在该路径不可强制、Profile 新鲜度告警、`memory_turn_closeout_v1` 收尾。 +- 任务目录被删除后遗留的 TaskRecoveryBinding 不再污染后续准入:PreToolUse 重放前清除孤立绑定(`lastResumeIngressRehydrate.reasonCode=task-root-missing`),`memory_task_admit_v2` 的 bounded-resume 回退忽略孤立绑定,省略 `ingressRef` 的准入恢复正常。 +- Codex CLI Skill 路由能力由 `PASS` 降为 `UNVERIFIED`(`defaultEligible=false`):`task-recovery-store-v5.cjs` 等运行时契约文件变更后,旧 S15 证据的 `runtimeContractDigest`/`hostAdapterDigest` 已过期;在 codex-cli 0.155.1 上用 `probe-skill-route-s15-host.js --host codex`(隔离与 production 模式)重放均因宿主恢复指令要求提交后立即 load_stage 而无法完成条件重规划,未取得可提升的新鲜证据。旧证据文件保留作历史,待真实宿主重放通过后用 `refresh-host-skill-route-capability.js` 重新提升。 +- `memory_turn_closeout_v1` 新增 `terminal`:用本轮真实数据(ECR 复审项、完成项)在 `/evidence/closeout/` 生成 ECR 与完成清单,引用同一调用写入的报告与会话记忆摘要,读取 owner CAS 后执行受围栏的 `memory_task_terminal_v1`;`completed` 仍要求已验证 CP1。补齐此前 terminal 四类证据缺少生产者的缺口。 +- 驱动宿主写入 `DEVCODEX_HOST_VARIANT`,turn-begin 的 SkillRouteBootstrap 报告 `grok-bot/driver-synthesized-local-stdio`。 +- `devcodex profile init` 对 DevCodex 源码仓库自身生成 ProfileCurrentTruthV1 与 currentTruthRef:仅取 package.json、本地 git/候选身份、CI 矩阵与公开 GitHub Release/workflow 回执;证据缺失保持 UNVERIFIED,由未改动的 ProfileCurrentTruthGate 拒绝。 +- 使用前提:在父级工作区(如 `/workspace`)执行 `devcodex init`,不要在仓库内初始化;各项目再执行 `devcodex profile init`。 +- 限制:事件由驱动合成,PreToolUse 仅观测不强制拦截;回归见 `npm run test:grok-bot-host`。 + ## 全项目审查问题修复 - MCP supervisor 固定客户端已协商的 tools/prompts 契约;worker 崩溃后先复核候选,schema 漂移返回明确的重启错误。外层 stdin 使用共享有界 JSONL reader,超大帧拒绝后仍能处理合法请求。 diff --git a/content/duplication-inventory.json b/content/duplication-inventory.json index 7c97a914..1d53c9cf 100644 --- a/content/duplication-inventory.json +++ b/content/duplication-inventory.json @@ -1,6 +1,6 @@ { "schemaVersion": "ControlContentDuplicationInventoryV1", - "sourceBundleDigest": "dbb6e63c39ced5100c50a8deddd47427a170bb59594b249ecc2afb3f661f2466", + "sourceBundleDigest": "034cda1c943f913bfcb24cc58203d4256e35e0d0e797193206d3cfde6f2bb785", "thresholds": { "minParagraphChars": 100, "sectionThreshold": 0.94, diff --git a/content/prompts/precheck-status.prompt.md b/content/prompts/precheck-status.prompt.md index 05669474..34c92251 100644 --- a/content/prompts/precheck-status.prompt.md +++ b/content/prompts/precheck-status.prompt.md @@ -15,15 +15,15 @@ applyTo: "**" | 检查项 | 状态 | 结论(人话;禁止进度缩写) | |----|----|----------------------------| -| PC0 中断处理 | `PASS` | 版本:已安装 package version · 活动 runtime generation · 当前配置 runtime generation · 是否需要重启 · 可选源码候选 `{packageVersion, shortHead, dirty}` · alignment | +| PC0 版本与运行 | `PASS` | 版本:已安装 package version · 活动 runtime generation · 当前配置 runtime generation · 是否需要重启 · 可选源码候选 `{packageVersion, shortHead, dirty}` · alignment | | PC1 当前意图 | `PASS` | 意图:语义初判 → 项目现实扩展后的最终路由/工作流 | -| PC2 项目绑定 | `PASS` | 会话:轮次/Token · 待跟进 | -| PC3 确认边界 | `PASS` | 执行准备:唯一项目 · 连续性 · 产物落点(禁止「写报告 02」式施工日志) | +| PC2 会话 | `PASS` | 会话:轮次/Token · 待跟进 | +| PC3 执行准备 | `PASS` | 执行准备:唯一项目 · 连续性 · 产物落点(禁止「写报告 02」式施工日志) | | PC4 规范雷达 | `N/A` | 规范雷达:dev 完整;非 dev=`N/A` + skipReason | -| PC5 本轮范围 | `UNVERIFIED` | 宿主:名称 + Full/Partial · 部署/同步证据(legacy 诊断可覆盖 `.github/`、`.claude/`、根 `AGENTS.md`、`.agents/`、`.codex/`;GlobalOnly 以 doctor/receipt 为准) | -| PC6 Dirty 边界 | `PASS` | 工作区:git dirty 范围 · 任务目录 | -| PC7 上下文 | `PASS` | 续接:新会话/resume 有界检测 | -| PC8 风险等级 | `PASS` | 流程与方案:初判/二次判断的 ceremonyTier、designDepth 及差异原因 | +| PC5 宿主 | `UNVERIFIED` | 宿主:名称 + Full/Partial · 部署/同步证据(legacy 诊断可覆盖 `.github/`、`.claude/`、根 `AGENTS.md`、`.agents/`、`.codex/`;GlobalOnly 以 doctor/receipt 为准) | +| PC6 工作区 | `PASS` | 工作区:git dirty 范围 · 任务目录 | +| PC7 续接 | `PASS` | 续接:新会话/resume 有界检测 | +| PC8 流程与方案 | `PASS` | 流程与方案:初判/二次判断的 ceremonyTier、designDepth 及差异原因 | | PC9 验证 | `PASS` | 验证计划:assuranceLevel · targeted/affected/full 数量 · CI/package/install/release · 预计时长 | | PC10 下一步 | `PASS` | 后续动作:下一阶段 · 是否自动继续 · 用户当前动作 · 如何修正判断 | diff --git a/content/skills/portfolio.json b/content/skills/portfolio.json index b73d8730..2b834d3b 100644 --- a/content/skills/portfolio.json +++ b/content/skills/portfolio.json @@ -6,17 +6,17 @@ "skillsPattern": "content/skills/*/SKILL.md", "optionalSidecar": "content/skills/*/devcodex.skill.json", "registry": "plugin.json", - "sourceDigest": "67c010bb3feae21da186da62f59250aee200c90aa17ab34b36690759f40810eb", + "sourceDigest": "551b528e53b10b866a48cc8cb3f4b858a85de28930fb88f88ecfc03de34b760c", "sidecarDigest": "774105d7947b340d15d17115fcb8e37b62ae545cf553f06126d529184fd74872", "pluginDigest": "16cf44508be1000903d8d9f5ebf7b1a97a32a3806646dd4af2f2f1aed4a68e7c", "publicTaxonomy": "content/skills/public-taxonomy.json", "publicTaxonomyDigest": "7f6ee8c30f5484b8881e27099af72c69625b73a7cf3b53fe51c8c0c147d4f124", "portfolioEvidence": "content/skills/portfolio-evidence.json", "portfolioEvidenceDigest": "cfd709b1248855c06c56373d1bcfe4798acefd7f1e0c11555b26cfd8caa1e8a6", - "consumerInventoryFileCount": 731, - "consumerInventoryDigest": "54a64aa98ede8816cdccee8a821adf8a75bfd30921b90deb1b631f8a7d260508", - "consumerProjectionDigest": "abdd741feaa9bda23d8a7b02944ed8fc7baa3d966b60d8f649e20e15f21175c8", - "portfolioInputDigest": "5ba18148cfefc6a2e95544b4a20ea6650d747d905817288d03207759b6a1a94c" + "consumerInventoryFileCount": 735, + "consumerInventoryDigest": "c2f71f2924d163753b5dab8146ddefb15d1d343d2ff8201957fe4e5aba513231", + "consumerProjectionDigest": "2b09ec9588ad0e960884aaf0cf16520f87e8844840f281d36d0d1d53ce747fd9", + "portfolioInputDigest": "07007518e994791b588a490b63403b8b1bd46c99857ca3c37d55a8b78973e184" }, "ordering": "skills.id asc; graph edges from/to asc", "summary": { @@ -9378,6 +9378,10 @@ "path": "scripts/lib/validate-rework-trust-controls.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-host-parity-scorecard.js", "role": "current" @@ -9400,6 +9404,7 @@ } ], "validationProfile": [ + "scripts/test-grok-bot-host.js", "scripts/test-host-parity-scorecard.js", "scripts/test-repair-prevention-assessment.js", "scripts/test-requirement-parallel-orchestration.js", @@ -9452,6 +9457,7 @@ ], "evolvableUnitRef": "skill:fix-default", "probeSuiteRefs": [ + "scripts/test-grok-bot-host.js", "scripts/test-host-parity-scorecard.js", "scripts/test-repair-prevention-assessment.js", "scripts/test-requirement-parallel-orchestration.js", @@ -11125,6 +11131,10 @@ "path": "scripts/lib/global-host-config.js", "role": "current" }, + { + "path": "scripts/lib/grok-bot-driver.js", + "role": "current" + }, { "path": "scripts/lib/host-capability-routing.js", "role": "current" @@ -11317,6 +11327,10 @@ "path": "scripts/test-governance-intake.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-hooks-ingress-continuity.js", "role": "current" @@ -11479,6 +11493,7 @@ "scripts/test-feature-inventory-evidence.js", "scripts/test-global-skill-runtime-root.js", "scripts/test-governance-intake.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -11597,6 +11612,7 @@ "scripts/test-feature-inventory-evidence.js", "scripts/test-global-skill-runtime-root.js", "scripts/test-governance-intake.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -12304,6 +12320,10 @@ "path": "hooks/_runtime/evidence/grok-skill-route-pass.v1.json", "role": "current" }, + { + "path": "hooks/_runtime/host-session-partition.cjs", + "role": "current" + }, { "path": "hooks/_runtime/host-tool-mutation-adapters.cjs", "role": "current" @@ -12612,6 +12632,10 @@ "path": "scripts/lib/governance-ledger-resolver.js", "role": "current" }, + { + "path": "scripts/lib/grok-bot-driver.js", + "role": "current" + }, { "path": "scripts/lib/grok-workspace-launcher.js", "role": "current" @@ -12816,6 +12840,10 @@ "path": "scripts/test-governance-status-summary.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-hooks-ingress-continuity.js", "role": "current" @@ -13016,6 +13044,7 @@ "scripts/test-global-install-smoke.js", "scripts/test-governance-intake.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -13119,6 +13148,7 @@ "scripts/test-global-install-smoke.js", "scripts/test-governance-intake.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -14063,6 +14093,10 @@ "path": "scripts/lib/governance-ledger-migration.js", "role": "current" }, + { + "path": "scripts/lib/grok-bot-driver.js", + "role": "current" + }, { "path": "scripts/lib/grok-workspace-launcher.js", "role": "current" @@ -14439,6 +14473,10 @@ "path": "scripts/test-governance-status-summary.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-hooks-ingress-continuity.js", "role": "current" @@ -14721,6 +14759,7 @@ "scripts/test-governance-intake.js", "scripts/test-governance-ledger-resolver.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -14858,6 +14897,7 @@ "scripts/test-governance-intake.js", "scripts/test-governance-ledger-resolver.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-ingress-continuity.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", @@ -17054,6 +17094,10 @@ "path": "scripts/lib/global-adapters-cli.js", "role": "current" }, + { + "path": "scripts/lib/grok-bot-driver.js", + "role": "current" + }, { "path": "scripts/lib/host-parity-scorecard.js", "role": "current" @@ -17286,6 +17330,10 @@ "path": "scripts/test-global-runtime-continuity.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-hooks-runtime.js", "role": "current" @@ -17461,6 +17509,7 @@ "scripts/test-global-host-removal.js", "scripts/test-global-install-smoke.js", "scripts/test-global-runtime-continuity.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", "scripts/test-host-capability-routing.js", @@ -17567,6 +17616,7 @@ "scripts/test-global-host-removal.js", "scripts/test-global-install-smoke.js", "scripts/test-global-runtime-continuity.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-runtime.js", "scripts/test-host-adapters.js", "scripts/test-host-capability-routing.js", @@ -20364,6 +20414,10 @@ "path": "changelogs/unreleased.md", "role": "current" }, + { + "path": "grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md", + "role": "current" + }, { "path": "hooks/_runtime/artifact-template-contract.cjs", "role": "current" @@ -20528,6 +20582,10 @@ "path": "scripts/lib/governance-status-summary.js", "role": "current" }, + { + "path": "scripts/lib/grok-bot-driver.js", + "role": "current" + }, { "path": "scripts/lib/memory-index.js", "role": "current" @@ -20688,6 +20746,10 @@ "path": "scripts/test-governance-status-summary.js", "role": "current" }, + { + "path": "scripts/test-grok-bot-host.js", + "role": "current" + }, { "path": "scripts/test-hooks-runtime.js", "role": "current" @@ -20801,6 +20863,7 @@ "scripts/test-global-install-smoke.js", "scripts/test-governance-intake.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-runtime.js", "scripts/test-host-capability-routing.js", "scripts/test-lifecycle-stop-gate.js", @@ -20882,6 +20945,7 @@ "scripts/test-global-install-smoke.js", "scripts/test-governance-intake.js", "scripts/test-governance-status-summary.js", + "scripts/test-grok-bot-host.js", "scripts/test-hooks-runtime.js", "scripts/test-host-capability-routing.js", "scripts/test-lifecycle-stop-gate.js", @@ -21690,8 +21754,8 @@ "content/skills/user-visible-output-contract/SKILL.md" ], "source": "content/skills/user-visible-output-contract/SKILL.md", - "hash": "2877ea53f4e7b53ef6ac14b95b42f555dc3fa78d722b4fbb4d95fcb60f413b0f", - "sourceBytes": 26296, + "hash": "ba9afc969c8e7092fde8bcf441c631099d465e4d71df921ae0938eb88a69b166", + "sourceBytes": 26299, "version": "1.20.7", "lifecycleState": "active", "publicCategory": "runtime-governance", diff --git a/content/skills/user-visible-output-contract/SKILL.md b/content/skills/user-visible-output-contract/SKILL.md index c243fa70..d5959eed 100644 --- a/content/skills/user-visible-output-contract/SKILL.md +++ b/content/skills/user-visible-output-contract/SKILL.md @@ -262,7 +262,7 @@ dev / fix / self-fix 的 `completion-check` 或 dev 模式合规块宣告完成 | 规则 | 说明 | |------|------| -| 分列必齐 | 须能识别 **PC0…PC10 各自独立** 行/单元格(推荐表格行含检查项、状态、结论三列,例如 `PC0 中断处理 / PASS / …`,兼容旧表格与列表形式);缺任一 → incomplete | +| 分列必齐 | 须能识别 **PC0…PC10 各自独立** 行/单元格(推荐表格行含检查项、状态、结论三列,例如 `PC0 版本与运行 / PASS / …`,兼容旧表格与列表形式);缺任一 → incomplete | | 禁止折叠 | `PC2–PC10` / `PC2-10` / `PC2~PC10` 等合并范围 → `pc-folded-range`,precheck=`verified-missing` | | 禁止施工日志 | PC3/PC6 等单元格禁止「写报告 02 / 见下清单 / 只读+复现」等进度缩写冒充语义 | | PC0 版本 | PC0 行须区分 installed package、活动 runtime generation、可选源码候选与 alignment;仅版本号相同不得写 `aligned` | diff --git a/grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md b/grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md index 6228bad6..328ff4e6 100644 --- a/grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md +++ b/grok/plugins/devcodex-workspace/skills/devcodex-workspace/SKILL.md @@ -30,6 +30,7 @@ Grok **UserPromptSubmit** cannot inject model context (stdout ignored). Workspac |------|-------|------------|----------------| | **Full** | `devcodex grok` (official `--rules` kernel bind) | Controlling `AGENTS.md` in context; host-owned tool permissions; path-observable context and unified staged skill route; workflow/Stop diagnostics | UserPromptSubmit context inject; forced continuation from wording gaps; verified PC0 from Hook payload alone | | **Partial** | plain `grok` in a child Git project | Best-effort Skill discovery + advisory plugin bridge when reachable | Full host parity with Codex; `kernelInjected=true` from passive Hook stdout | +| **Grok Bot driver** (Partial-like) | Grok Bot assistant with mounted `devcodex-profile`/`devcodex-memory` stdio connectors (`DEVCODEX_AGENT=grok-bot`) + `devcodex grok-bot` driver | Lifecycle events synthesized by the driver (`devcodex grok-bot turn-begin` / `observe-tool`); same ContextRead → `skill_route` → admission contract; PC5 reports this tier as `WARN` | Native hooks, context inject, enforced PreToolUse, Full; driver events as host-emitted evidence | Platform facts (Grok Build hooks docs): `PreToolUse` and `Stop`/`SubagentStop` can hard-control; UserPromptSubmit is passive (no inject). PC5 must report `Partial` unless Full launcher evidence is present. Do not equate Grok with Codex UPS inject bootstrap. @@ -44,6 +45,23 @@ Platform facts (Grok Build hooks docs): `PreToolUse` and `Stop`/`SubagentStop` c 9. Execute **GrokTurnChecklist** every non-trivial turn (PF-165 + C16/PI-20260724-01): PC0~PC10 → Intent→Skill mandatory bundle → ContextReadPlan → **scan-hygiene** → **TTFV first delivery** → work/gates → report+memory → honest platform ceiling. Never skip S05/S07/C17 because inject is missing. 10. **Workspace skill trigger**: if user message matches a W skill, obey that skill before checklist bulk for chat-like turns. +## Grok Bot (no native hooks) + +Grok Bot has no lifecycle hooks, so the model drives them. The kernel's rules apply unchanged; this only adds the event plumbing. + +**Session id (required for concurrent conversations).** Pick one id per Grok Bot conversation (`[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, e.g. the conversation/agent id) and use it everywhere in that conversation: `--session-id ` on `turn-begin` and on every `observe-tool`, and `"hostSession": ""` in the args of every connector call (the args file must be the exact args sent). Lifecycle, recovery, admission and route state live under `.memory/hooks//.sessions//`, so conversations on the same project never overwrite each other and a task admitted in one session can only be closed from that session. The connector rejects an invalid `hostSession` or a `contextBinding` minted for another session (`HOST_SESSION_INVALID`); the driver rejects a bracket whose args name another session or omit it (`GROK_BOT_HOST_SESSION_MISMATCH` / `GROK_BOT_HOST_SESSION_REQUIRED`). Without `--session-id` the shared default `grok-bot-session` is used (state stays at `.memory/hooks//`) — only safe when a single conversation is active. + +1. Once per user message, from the project dir: `devcodex grok-bot turn-begin ""`. Keep `contextEpoch`, `turnBinding`, `skillRouteBootstrap`, `semanticDecisionSourceRef`. Never repeat it within the turn. +2. Bracket **every** DevCodex connector call with the same id and args file: + `devcodex grok-bot observe-tool --project-dir --phase pre --tool devcodex-__ --tool-use-id --args-file `, then the call, then the same command with `--phase post --response-file ` (the driver strips the host's untrusted-data fence). A skipped or duplicated bracket breaks the context binding (`scope-drift` / `receipt-status-stale`). +3. Entry check first (S07): bracketed `profile_compose_entry_check` with `entry: { prompt, specRadar?, activeTask?, languageDecision? }`; emit the returned PC0~PC10 table verbatim (it renders in the prompt's language unless `languageDecision` says otherwise). PC5 shows the Grok Bot driver tier (`WARN`, never Full); PC6 shows observed Git state; PC4 is `PASS` in dev mode only when `specRadar` names the bound Skills/Profile. +4. Read `turn-begin` `warnings` / `profileFreshness`: `PROFILE_TRUTH_DRIFT` or `PROFILE_POSSIBLY_STALE` means the Profile may not match the code; say so and refresh it when the turn depends on it. +5. Then the kernel route: `profile_context_plan` (with `semanticDecision.sourceRef`) → `profile_load` (pass `files`) → required memory queries → `skill_route` catalog (paginate) + commit → `memory_task_admit_v2` (omit `ingressRef`; any `ingressWarnings` means a supplied ref was ignored). +6. Closeout (S05): one bracketed `memory_turn_closeout_v1` call writes the optional report (new file under the task's `reports/`, same authority as the operational write lease), the session row and status, and the SUMMARY entry. Keep `sessionStatus: in-progress` until a formal task's terminal closeout. To close a formal task in the same call, add `terminal: {taskId, terminalStatus, ecr: {summary, checks: [{item, result}]}, completion: {items: [{item, status}]}}`. The server renders the ECR and the completion checklist from that real turn data under `/evidence/closeout/`, cites the report and session-memory digests, reads the owner CAS state and runs the fenced `memory_task_terminal_v1` transition. `completed` still requires a verified CP1; otherwise use `cancelled`, `failed` or `rejected` honestly. +7. S04 limit: Grok Bot edits files through its own shell/file tools, so DevCodex cannot see or block whole-file rewrites. S04 (incremental edits only) is not enforceable on this path and relies on model discipline; prefer targeted patch edits and never regenerate whole source/spec files. +8. Prerequisites: `devcodex init` at the parent workspace (not inside a repo), `devcodex profile init` per project, connectors started after the workspace exists. Run the connectors and the driver from a clean runtime copy (default `/workspace/devcodex-runtime`), never from a development checkout; to pick up new DevCodex code run `scripts/grok-bot-runtime.sh update` then `scripts/grok-bot-runtime.sh restart`. +9. Driver events are recorded with provenance `driver-synthesized-host-event` (accepted for the `grok-bot` host variant only), never as native `trusted-host-event`. + ## GrokTurnChecklist + Intent→Skill bundle (PF-165) | Step | Must do | diff --git a/hooks/_runtime/actual-instruction-envelope.cjs b/hooks/_runtime/actual-instruction-envelope.cjs index 55af6647..4483b958 100644 --- a/hooks/_runtime/actual-instruction-envelope.cjs +++ b/hooks/_runtime/actual-instruction-envelope.cjs @@ -92,6 +92,27 @@ const EMBEDDED_NON_INSTRUCTION_BLOCKS = Object.freeze([ } ]) +// Driver-synthesized lifecycle events (hosts without native hooks, e.g. Grok Bot) carry their own +// provenance level. It grants the same workflow-ingress authority as a native host event, but only for +// host variants registered as driver-synthesized; every other host keeps 'trusted-host-event'. +const DRIVER_SYNTHESIZED_PROVENANCE = 'driver-synthesized-host-event' +const DRIVER_SYNTHESIZED_HOST_VARIANTS = Object.freeze(['grok-bot/driver-synthesized-local-stdio']) +const PROVENANCE_LEVELS = Object.freeze(['trusted-host-event', DRIVER_SYNTHESIZED_PROVENANCE, 'caller-attested-portable']) + +function isDriverSynthesizedHostVariant(hostVariant) { + return DRIVER_SYNTHESIZED_HOST_VARIANTS.includes(String(hostVariant || '')) +} + +function isTrustedIngressProvenance(level) { + return level === 'trusted-host-event' || level === DRIVER_SYNTHESIZED_PROVENANCE +} + +function isTrustedHostIngressEnvelope(envelope) { + if (!envelope) return false + if (envelope.provenanceLevel === 'trusted-host-event') return true + return envelope.provenanceLevel === DRIVER_SYNTHESIZED_PROVENANCE && isDriverSynthesizedHostVariant(envelope.hostVariant) +} + function stableStringify (value) { if (value === null || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` @@ -342,7 +363,9 @@ function buildActualInstructionEnvelope (payload = {}, options = {}) { const sourceEventId = observedSourceEventId ? `event-${digest(observedSourceEventId).slice(0, 40)}` : `portable-${digest({ hostVariant, hostSessionDigest, turnId, actualInstructionDigest }).slice(0, 40)}` - const provenanceLevel = trustedHostEvent ? 'trusted-host-event' : 'caller-attested-portable' + const provenanceLevel = trustedHostEvent + ? (isDriverSynthesizedHostVariant(hostVariant) ? DRIVER_SYNTHESIZED_PROVENANCE : 'trusted-host-event') + : 'caller-attested-portable' const contextEpoch = boundedString(options.contextEpoch, 256) const attachments = combineBoundedSegments( @@ -478,8 +501,11 @@ function validateActualInstructionEnvelope (envelope) { })) errors.push('segment-set-digest') if (envelope.instructionAuthority !== (envelope.actualInstructionBytes > 0)) errors.push('instruction-authority') if (!['trusted-host-workflow-ingress', 'portable-plan-only'].includes(envelope.authorityScope)) errors.push('authority-scope') - if (!['trusted-host-event', 'caller-attested-portable'].includes(envelope.provenanceLevel)) errors.push('provenance-level') - if ((envelope.provenanceLevel === 'trusted-host-event') !== (envelope.authorityScope === 'trusted-host-workflow-ingress')) { + if (!PROVENANCE_LEVELS.includes(envelope.provenanceLevel)) errors.push('provenance-level') + if (envelope.provenanceLevel === DRIVER_SYNTHESIZED_PROVENANCE && !isDriverSynthesizedHostVariant(envelope.hostVariant)) { + errors.push('driver-provenance-host-variant') + } + if (isTrustedIngressProvenance(envelope.provenanceLevel) !== (envelope.authorityScope === 'trusted-host-workflow-ingress')) { errors.push('provenance-authority-mismatch') } for (const kind of ['attachments', 'quotedDocuments', 'ambientState', 'evidenceSegments']) { @@ -639,6 +665,11 @@ function validateWorkItemSet (set, envelope = null) { module.exports = { ACTUAL_INSTRUCTION_ENVELOPE_SCHEMA, + DRIVER_SYNTHESIZED_PROVENANCE, + PROVENANCE_LEVELS, + isDriverSynthesizedHostVariant, + isTrustedHostIngressEnvelope, + isTrustedIngressProvenance, DEFAULT_TTL_MS, MAX_INSTRUCTION_BYTES, MAX_WORK_ITEMS, diff --git a/hooks/_runtime/host-adapter-identity.cjs b/hooks/_runtime/host-adapter-identity.cjs index fab7b871..5657760c 100644 --- a/hooks/_runtime/host-adapter-identity.cjs +++ b/hooks/_runtime/host-adapter-identity.cjs @@ -13,6 +13,7 @@ const HOST_VARIANTS = Object.freeze({ gemini: 'gemini-cli/headless-user-global-local-stdio', grok: 'grok-cli-single/global-launcher-local-stdio', grokpassive: 'grok/passive-plain-instruction-only', + grokbot: 'grok-bot/driver-synthesized-local-stdio', cursor: 'cursor/local-ide-cli-headless-user-global-stdio-beta', cursorcloud: 'cursor/cloud-agent-instruction-only' }) @@ -24,6 +25,7 @@ const HOST_ENTRY_SURFACES = Object.freeze({ copilot: 'copilot-cli-print', gemini: 'gemini-cli-headless', grok: 'grok-cli-single', + grokbot: 'grok-bot-driver', cursor: 'cursor-cli-headless' }) @@ -41,6 +43,8 @@ const HOST_ALIASES = Object.freeze({ 'gemini-cli': 'gemini', 'grok-cli-single': 'grok', 'grok-single': 'grok', + 'grok-bot': 'grokbot', + 'grok-bot-driver': 'grokbot', 'cursor-cli': 'cursor', 'cursor-ide': 'cursor' }) @@ -132,6 +136,7 @@ function directExecutionSurface (hostId, options = {}) { payload.devcodexTrustedHook === true return trustedHook ? 'trusted-hook-or-plugin' : 'passive-plain' } + if (hostId === 'grokbot') return 'driver-synthesized' if (hostId === 'claude') return 'cli' if (hostId === 'gemini') return 'cli' return explicitSurface || 'portable-plain' diff --git a/hooks/_runtime/host-session-partition.cjs b/hooks/_runtime/host-session-partition.cjs new file mode 100644 index 00000000..d8af712c --- /dev/null +++ b/hooks/_runtime/host-session-partition.cjs @@ -0,0 +1,118 @@ +'use strict' + +// Per-session lifecycle partition for driver-synthesized hosts (Grok Bot). +// +// Native hosts own one lifecycle state per project namespace. Grok Bot runs many +// conversations against the same workspace through one pair of long-lived +// connectors, so its lifecycle/recovery/admission/route state is keyed by the +// conversation's host session id: .memory/hooks//.sessions// (the default +// id grok-bot-session keeps .memory/hooks//). +// Every other host keeps the unchanged per-project partition. + +const DRIVER_SESSION_HOST_VARIANT = 'grok-bot/driver-synthesized-local-stdio' +const DRIVER_SESSION_AGENT = 'grok-bot' +const SESSION_SEGMENT = '.sessions' +const DEFAULT_DRIVER_SESSION_ID = 'grok-bot-session' +const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ + +function isDriverSessionHost(env = process.env) { + return env.DEVCODEX_HOST_VARIANT === DRIVER_SESSION_HOST_VARIANT || + String(env.DEVCODEX_AGENT || '').trim().toLowerCase() === DRIVER_SESSION_AGENT +} + +function isValidHostSessionId(value) { + return typeof value === 'string' && SESSION_ID_RE.test(value) && !/^\.+$/.test(value) +} + +// Path segments appended below .memory/hooks/ for the current process session. +function hostSessionPartitionSegments(env = process.env) { + if (!isDriverSessionHost(env)) return [] + const sessionId = String(env.DEVCODEX_HOST_SESSION_ID || '').trim() || DEFAULT_DRIVER_SESSION_ID + if (!isValidHostSessionId(sessionId)) { + const error = new Error('driver-synthesized hosts require a valid DEVCODEX_HOST_SESSION_ID ([A-Za-z0-9._-], max 64)') + error.code = 'HOST_SESSION_ID_INVALID' + throw error + } + // The single-conversation default keeps the pre-isolation per-project location, so state + // created before session partitioning (and its active tasks) stays reachable. + return sessionId === DEFAULT_DRIVER_SESSION_ID ? [] : [SESSION_SEGMENT, sessionId] +} + +function hostSessionError(message) { + return { + content: [{ type: 'text', text: `Error: HOST_SESSION_INVALID: ${message}` }], + structuredContent: { schemaVersion: 'MemoryWriterErrorV1', errorCode: 'HOST_SESSION_INVALID', message, nextStep: 'Pass the same hostSession that devcodex grok-bot turn-begin returned as sessionId.' }, + isError: true + } +} + +// A ContextReadBinding is minted for one session's context epoch. Reject a call whose +// binding belongs to another session (its epoch is not this session's current epoch). +function bindingSessionMismatch(args, sessionId) { + const binding = args && args.contextBinding + if (!binding || typeof binding !== 'object' || !binding.contextEpoch || !binding.activeRoot || !binding.project) return null + const fs = require('fs') + const path = require('path') + const statePath = path.join(String(binding.activeRoot), '.memory', 'hooks', String(binding.project), ...(sessionId === DEFAULT_DRIVER_SESSION_ID ? [] : [SESSION_SEGMENT, sessionId]), 'lifecycle-state.json') + let epoch = null + try { + epoch = JSON.parse(fs.readFileSync(statePath, 'utf8'))?.contextAcquisition?.contextEpoch || null + } catch { + return `session ${sessionId} has no lifecycle state for this contextBinding` + } + return epoch === binding.contextEpoch ? null : `contextBinding epoch ${binding.contextEpoch} does not belong to session ${sessionId}` +} + +const HOST_SESSION_PROPERTY = Object.freeze({ type: 'string', description: 'Grok Bot conversation session id (from turn-begin sessionId)' }) + +// Per-call session identity for driver-host connectors. Requests are served one at a +// time by the JSON-line transport queue, so the session id is scoped to exactly one +// call: tools/call may carry `hostSession`, which selects that session's partition for +// the call and is restored afterwards. tools/list advertises the optional field. +function wrapDispatchWithHostSession(dispatch, env = process.env) { + if (!isDriverSessionHost(env)) return dispatch + return function hostSessionDispatch(method, params) { + if (method === 'tools/list') { + const listed = dispatch(method, params) + const decorate = page => page && Array.isArray(page.tools) + ? { ...page, tools: page.tools.map(tool => ({ ...tool, inputSchema: { ...tool.inputSchema, properties: { ...(tool.inputSchema?.properties || {}), hostSession: HOST_SESSION_PROPERTY } } })) } + : page + return listed && typeof listed.then === 'function' ? listed.then(decorate) : decorate(listed) + } + const args = params?.arguments + if (method !== 'tools/call' || !args || typeof args !== 'object' || !Object.prototype.hasOwnProperty.call(args, 'hostSession')) { + return dispatch(method, params) + } + const sessionId = args.hostSession + if (!isValidHostSessionId(sessionId)) return hostSessionError('hostSession must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}') + const mismatch = bindingSessionMismatch(args, sessionId) + if (mismatch) return hostSessionError(mismatch) + const rest = { ...args } + delete rest.hostSession + const previous = env.DEVCODEX_HOST_SESSION_ID + env.DEVCODEX_HOST_SESSION_ID = sessionId + const restore = () => { + if (previous === undefined) delete env.DEVCODEX_HOST_SESSION_ID + else env.DEVCODEX_HOST_SESSION_ID = previous + } + let result + try { + result = dispatch(method, { ...params, arguments: rest }) + } catch (error) { + restore() + throw error + } + if (result && typeof result.then === 'function') return result.finally(restore) + restore() + return result + } +} + +module.exports = { + DRIVER_SESSION_HOST_VARIANT, + SESSION_SEGMENT, + isDriverSessionHost, + isValidHostSessionId, + hostSessionPartitionSegments, + wrapDispatchWithHostSession +} diff --git a/hooks/_runtime/host-skill-route-capabilities.v1.json b/hooks/_runtime/host-skill-route-capabilities.v1.json index a09f5df8..a8960209 100644 --- a/hooks/_runtime/host-skill-route-capabilities.v1.json +++ b/hooks/_runtime/host-skill-route-capabilities.v1.json @@ -10,16 +10,13 @@ }, { "hostVariant": "codex-cli/exec-user-global-local-stdio", - "status": "PASS", - "testedVersion": "codex-cli 0.145.0", + "status": "UNVERIFIED", + "testedVersion": "unverified", "protocol": "MCP 2024-11-05", - "evidenceRef": "hooks/_runtime/evidence/codex-skill-route-pass.v1.json", + "evidenceRef": null, "entrySurface": "codex exec --ephemeral", "bootstrapDelivery": "stable user-global Hook launcher UserPromptSubmit or profile_context_plan fallback", - "defaultEligible": true, - "runtimeContractDigest": "7ad988d1b94cf581e9d8acc2f1ca9fc67d34c6a6118d13f8a4f4d7c85896b306", - "hostAdapterDigest": "339d0db61f360a13ac7b41f88f1fde31262669cf5eea3177e66619a5c62a942a", - "evidenceDigest": "3be5d963a9873417c846ccf2033740cd61903a9f5a4b924536a4f6a2d0a2132d" + "defaultEligible": false }, { "hostVariant": "codex-desktop/app-user-global-local-stdio", @@ -55,6 +52,16 @@ "bootstrapDelivery": "global launcher --rules with stable Hook and profile_context_plan fallback", "defaultEligible": false }, + { + "hostVariant": "grok-bot/driver-synthesized-local-stdio", + "status": "UNVERIFIED", + "testedVersion": "unverified", + "protocol": "MCP 2024-11-05", + "evidenceRef": null, + "entrySurface": "devcodex grok-bot turn-begin|observe-tool|run", + "bootstrapDelivery": "driver-synthesized SessionStart/UserPromptSubmit plus bracketed Pre/PostToolUse (not native lifecycle events)", + "defaultEligible": false + }, { "hostVariant": "cursor/local-ide-cli-headless-user-global-stdio-beta", "status": "UNVERIFIED", diff --git a/hooks/_runtime/host-tool-mutation-adapters.cjs b/hooks/_runtime/host-tool-mutation-adapters.cjs index 6bf51cae..506b79ee 100644 --- a/hooks/_runtime/host-tool-mutation-adapters.cjs +++ b/hooks/_runtime/host-tool-mutation-adapters.cjs @@ -57,6 +57,7 @@ const DEVCODEX_RESERVED_MCP_TOOL_LEAVES = new Set([ 'memory_workflow_operational_write_lease', 'memory_task_terminal_v1', 'memory_task_closeout_reconcile_v1', + 'memory_turn_closeout_v1', 'memory_artifact_mutation_reconcile_v1' ]) diff --git a/hooks/_runtime/intent-semantic-decision.cjs b/hooks/_runtime/intent-semantic-decision.cjs index f4e39d62..83703d8c 100644 --- a/hooks/_runtime/intent-semantic-decision.cjs +++ b/hooks/_runtime/intent-semantic-decision.cjs @@ -1,6 +1,6 @@ 'use strict' -const { validateActualInstructionEnvelope } = require('./actual-instruction-envelope.cjs') +const { isTrustedHostIngressEnvelope, validateActualInstructionEnvelope } = require('./actual-instruction-envelope.cjs') const { normalizeLanguageTag } = require('./language-context.cjs') const { stableStringify } = require('./content-identity.cjs') const SCHEMA_VERSION = 'IntentSemanticDecisionV1' @@ -118,7 +118,7 @@ function validateIntentSemanticDecision(value, expected = {}) { if (expected.requireSource || expected.envelope) { const envelope = expected.envelope if (!validateActualInstructionEnvelope(envelope).valid || envelope.instructionAuthority !== true || - envelope.provenanceLevel !== 'trusted-host-event' || Date.parse(envelope.expiresAt) <= (expected.nowMs ?? Date.now()) || + !isTrustedHostIngressEnvelope(envelope) || Date.parse(envelope.expiresAt) <= (expected.nowMs ?? Date.now()) || ref?.envelopeId !== envelope.envelopeId || ref?.envelopeDigest !== envelope.envelopeDigest || ref?.contextEpoch !== envelope.contextEpoch || (expected.hostSessionDigest && envelope.hostSessionDigest !== expected.hostSessionDigest)) { diff --git a/hooks/_runtime/lifecycle-bootstrap-state.cjs b/hooks/_runtime/lifecycle-bootstrap-state.cjs index 8cb44086..a7cbe4ba 100644 --- a/hooks/_runtime/lifecycle-bootstrap-state.cjs +++ b/hooks/_runtime/lifecycle-bootstrap-state.cjs @@ -23,7 +23,7 @@ const { compactLifecycleStateV5 } = require('./lifecycle-state-projection-v5.cjs') const { resolveVisibleLocale } = require('./visible-output-contract.cjs') -const { validateActualInstructionEnvelope } = require('./actual-instruction-envelope.cjs') +const { isTrustedHostIngressEnvelope, validateActualInstructionEnvelope } = require('./actual-instruction-envelope.cjs') const { restoreTasklessReportRecoveryFromCanonical } = require('./task-operation-current-state-recovery.cjs') function renderGrokS07Assist(input = {}) { @@ -917,7 +917,7 @@ function buildLifecycleBootstrapStateUtils(ctx) { !pending.taskRecoveryBinding && acquisition?.targetResolved === false && acquisition.hostSessionId === sessionKey && envelope?.contextEpoch === acquisition.contextEpoch && envelope?.hostSessionDigest === crypto.createHash('sha256').update(sessionKey).digest('hex') && - envelope?.provenanceLevel === 'trusted-host-event' && validateActualInstructionEnvelope(envelope).valid + isTrustedHostIngressEnvelope(envelope) && validateActualInstructionEnvelope(envelope).valid const sameRichContext = metaProjection?.contextAcquisition?.hostSessionId === sessionKey && metaProjection?.contextAcquisition?.contextEpoch === acquisition?.contextEpoch if (exactPendingIngress) { @@ -986,7 +986,7 @@ function buildLifecycleBootstrapStateUtils(ctx) { metaProjection?.actualInstructionEnvelope?.contextEpoch === metaProjection?.contextAcquisition?.contextEpoch && metaProjection?.actualInstructionEnvelope?.hostSessionDigest === crypto.createHash('sha256').update(sessionKey).digest('hex') && - metaProjection?.actualInstructionEnvelope?.provenanceLevel === 'trusted-host-event' && + isTrustedHostIngressEnvelope(metaProjection?.actualInstructionEnvelope) && validateActualInstructionEnvelope(metaProjection?.actualInstructionEnvelope).valid const projectedProject = workspaceHint || pendingWorkspaceIngress ? '' diff --git a/hooks/_runtime/lifecycle-host-adapters.cjs b/hooks/_runtime/lifecycle-host-adapters.cjs index 65dde10c..2b3717cf 100644 --- a/hooks/_runtime/lifecycle-host-adapters.cjs +++ b/hooks/_runtime/lifecycle-host-adapters.cjs @@ -101,6 +101,34 @@ const EVENT_MAP = Object.freeze({ cursor: CURSOR_EVENT_MAP }) +// Driver-synthesized hosts reuse a native host event map but never claim to be +// native lifecycle events. `grok-bot` is fed by scripts/lib/grok-bot-driver.js. +const DRIVER_HOSTS = Object.freeze({ + 'grok-bot': Object.freeze({ + host: 'grok', + agent: 'grok-bot', + provenance: 'driver-synthesized', + hostVariant: 'grok-bot/driver-synthesized-local-stdio' + }) +}) + +function resolveDriverHost(host, payload = {}, env = process.env) { + const driver = DRIVER_HOSTS[host] + if (!driver) return { host, payload } + if (!env.DEVCODEX_AGENT) env.DEVCODEX_AGENT = driver.agent + if (!env.DEVCODEX_HOST_PLATFORM) env.DEVCODEX_HOST_PLATFORM = driver.host + if (!env.DEVCODEX_HOST_VARIANT && driver.hostVariant) env.DEVCODEX_HOST_VARIANT = driver.hostVariant + return { + host: driver.host, + payload: { + ...(payload || {}), + devcodexEventProvenance: driver.provenance, + devcodexDriverHost: host, + devcodexExecutionSurface: 'grok-bot-driver' + } + } +} + function adapterTracePath(env = process.env) { const raw = String(env.DEVCODEX_LIFECYCLE_TRACE || '').trim() if (!raw) return '' @@ -1054,7 +1082,8 @@ function applyCliEnvironmentOverrides (argv, env = process.env) { } if (require.main === module) { - const host = String(process.argv[2] || '').trim().toLowerCase() + const requestedHost = String(process.argv[2] || '').trim().toLowerCase() + const host = DRIVER_HOSTS[requestedHost] ? DRIVER_HOSTS[requestedHost].host : requestedHost if (!Object.prototype.hasOwnProperty.call(EVENT_MAP, host)) { process.stderr.write(`Unsupported DevCodex host adapter: ${host || '(missing)'}\n`) process.exit(2) @@ -1085,7 +1114,11 @@ if (require.main === module) { process.env.DEVCODEX_HOST_EVENT) { payload.hookEventName = process.env.DEVCODEX_HOST_EVENT } - const result = runHostAdapter(host, payload) + const driver = resolveDriverHost(requestedHost, payload) + const result = runHostAdapter(driver.host, driver.payload) + if (DRIVER_HOSTS[requestedHost] && result.output && typeof result.output === 'object') { + result.output.devcodexEventProvenance = DRIVER_HOSTS[requestedHost].provenance + } if (result.diagnostics) process.stderr.write(result.diagnostics) if (result.status !== 0) { process.stderr.write(`${result.error}\n`) @@ -1097,7 +1130,9 @@ if (require.main === module) { } module.exports = { + DRIVER_HOSTS, EVENT_MAP, + resolveDriverHost, STDIO_CHILD_TIMEOUT_MS, STDIO_MAX_FRAME_BYTES, applyCliEnvironmentOverrides, diff --git a/hooks/_runtime/lifecycle-namespace-state.cjs b/hooks/_runtime/lifecycle-namespace-state.cjs index 218cbbec..50ad386a 100644 --- a/hooks/_runtime/lifecycle-namespace-state.cjs +++ b/hooks/_runtime/lifecycle-namespace-state.cjs @@ -1,5 +1,7 @@ 'use strict' +const { hostSessionPartitionSegments } = require('./host-session-partition.cjs') + function buildLifecycleNamespaceStateUtils(ctx) { const { fs, @@ -45,7 +47,7 @@ function buildLifecycleNamespaceStateUtils(ctx) { } function buildStatePaths(namespaceRoot, scopeKey) { - const dir = path.join(namespaceRoot, '.memory', 'hooks', scopeKey) + const dir = path.join(namespaceRoot, '.memory', 'hooks', scopeKey, ...hostSessionPartitionSegments()) return { dir, file: path.join(dir, 'lifecycle-state.json'), diff --git a/hooks/_runtime/lifecycle.cjs b/hooks/_runtime/lifecycle.cjs index 96ba162a..eedb6990 100644 --- a/hooks/_runtime/lifecycle.cjs +++ b/hooks/_runtime/lifecycle.cjs @@ -76,6 +76,7 @@ const { appendTaskRecoveryTelemetry, readBoundedResumeIngressCapability, readFencedTaskWriteOwner, + isTaskRecoveryBindingOrphaned, readTaskRecoveryState, resolveTaskRecoveryMetaDir, transitionTaskScopedAutoContinuationGrant, @@ -110,6 +111,7 @@ const { } = require('./host-adapter-identity.cjs') const { buildActualInstructionEnvelope, + isDriverSynthesizedHostVariant, buildWorkItemSet, validateActualInstructionEnvelope } = require('./actual-instruction-envelope.cjs') @@ -735,7 +737,12 @@ function initializeWorkflowIngress( sourceEventId: serverOwnedSourceEventId }, { actualInstruction: prompt, - hostVariant: state.hostIdentity?.hostVariant || platform, + // Driver-synthesized events (no native hooks) carry the driver host variant so the envelope + // records driver-synthesized-host-event provenance instead of impersonating the native host. + hostVariant: (payload?.devcodexEventProvenance === 'driver-synthesized' && + isDriverSynthesizedHostVariant(process.env.DEVCODEX_HOST_VARIANT)) + ? process.env.DEVCODEX_HOST_VARIANT + : (state.hostIdentity?.hostVariant || platform), hostSessionId: getPayloadSessionKey(payload), turnId: state.turnLiveness?.turnKey, contextEpoch: state.contextAcquisition?.contextEpoch, @@ -1184,7 +1191,7 @@ function observeSimpleTaskFastPathLease(state, payload) { function observeWorkflowTaskTerminalReceipt(state, payload) { const toolLeaf = devCodexToolLeaf(getToolName(payload)) - if (!['memory_task_terminal_v1', 'memory_task_closeout_reconcile_v1'].includes(toolLeaf) || + if (!['memory_task_terminal_v1', 'memory_task_closeout_reconcile_v1', 'memory_turn_closeout_v1'].includes(toolLeaf) || payload.success === false || payload.is_error === true || payload.isError === true) return null const receipts = findWorkflowTaskTerminalReceipts(payload) if (receipts.length !== 1) { @@ -2224,6 +2231,12 @@ function hasFreshCurrentTrustedHostIngress(state, payload) { function rehydrateBoundedResumeIngress(state, payload) { const binding = state?.taskRecoveryBinding if (!binding?.taskId || !binding?.project) return { status: 'skipped', reasonCode: 'task-binding-missing' } + if (isTaskRecoveryBindingOrphaned(binding)) { + // The bound task folder no longer exists: drop the binding instead of rehydrating its ingress. + state.taskRecoveryBinding = null + state.resumeIngressRehydrateError = null + return { status: 'skipped', reasonCode: 'task-root-missing', taskId: binding.taskId } + } if (hasFreshCurrentTrustedHostIngress(state, payload)) { state.resumeIngressRehydrateError = null return { status: 'skipped', reasonCode: 'current-trusted-host-ingress-active' } diff --git a/hooks/_runtime/task-recovery-store-v5.cjs b/hooks/_runtime/task-recovery-store-v5.cjs index 19931e78..ce9bc671 100644 --- a/hooks/_runtime/task-recovery-store-v5.cjs +++ b/hooks/_runtime/task-recovery-store-v5.cjs @@ -1,6 +1,7 @@ 'use strict' const fs = require('fs') +const { hostSessionPartitionSegments } = require('./host-session-partition.cjs') const os = require('os') const path = require('path') const crypto = require('crypto') @@ -39,7 +40,8 @@ const { const { compactLanguageContext, packRecoveryLanguageContext } = require('./language-context.cjs') const { validateActualInstructionEnvelope, - validateWorkItemSet + validateWorkItemSet, + isTrustedHostIngressEnvelope } = require('./actual-instruction-envelope.cjs') const { validateIntentSemanticDecision } = require('./intent-semantic-decision.cjs') const { @@ -434,7 +436,7 @@ function resolveTaskRecoveryMetaDir(input = {}) { activeRoot = path.join(anchor, 'workspace') partition = 'workspace' } - return path.join(activeRoot, '.memory', 'hooks', partition) + return path.join(activeRoot, '.memory', 'hooks', partition, ...hostSessionPartitionSegments()) } function createTaskRecoveryKey(input = {}) { @@ -2196,6 +2198,13 @@ function writeBoundedResumeIngressCapability(input = {}, options = {}) { } } +// A TaskRecoveryBinding whose task folder was removed outside DevCodex (e.g. a manual rm) +// must not drive resume rehydration or admission continuation for a later turn. +function isTaskRecoveryBindingOrphaned(binding, fsImpl = fs) { + if (!binding || typeof binding.taskRoot !== 'string' || !binding.taskRoot) return false + try { return !fsImpl.statSync(binding.taskRoot).isDirectory() } catch { return true } +} + function readBoundedResumeIngressCapability(input = {}, options = {}) { const fsImpl = options.fs || fs let committed = null @@ -4498,7 +4507,7 @@ function hasPendingWorkflowIngress(state) { const envelope = state?.actualInstructionEnvelope const context = state?.contextAcquisition || {} return !state?.taskRecoveryBinding && !state?.workflowRouteDecision && - envelope?.provenanceLevel === 'trusted-host-event' && envelope.instructionAuthority === true && + isTrustedHostIngressEnvelope(envelope) && envelope.instructionAuthority === true && envelope.contextEpoch === context.contextEpoch && Boolean(context.hostSessionId) && envelope.hostSessionDigest === rawHostSessionDigest(context.hostSessionId) && validateActualInstructionEnvelope(envelope).valid && @@ -7862,6 +7871,7 @@ function reconcileEmergencyTaskCloseout(input = {}, options = {}) { } module.exports = { + isTaskRecoveryBindingOrphaned, ADMISSION_CONTINUATION_LEASE_SCHEMA, ADMISSION_INGRESS_SNAPSHOT_REF_SCHEMA, ADMISSION_INGRESS_SNAPSHOT_SCHEMA, diff --git a/hooks/_runtime/visible-output-contract.cjs b/hooks/_runtime/visible-output-contract.cjs index 684c3734..925c646a 100644 --- a/hooks/_runtime/visible-output-contract.cjs +++ b/hooks/_runtime/visible-output-contract.cjs @@ -80,28 +80,28 @@ const ENTRY_CHECK_TABLE_HEADERS = Object.freeze({ }) const ENTRY_CHECK_LABELS = Object.freeze({ 'zh-CN': Object.freeze([ - '中断处理', + '版本与运行', '当前意图', - '项目绑定', - '确认边界', + '会话', + '执行准备', '规范雷达', - '本轮范围', - 'Dirty 边界', - '上下文', - '风险等级', + '宿主', + '工作区', + '续接', + '流程与方案', '验证', '下一步' ]), en: Object.freeze([ - 'Interruption', + 'Version', 'Intent', - 'Project', - 'Boundary', + 'Session', + 'Readiness', 'Spec radar', - 'Scope', - 'Dirty boundary', - 'Context', - 'Risk', + 'Host', + 'Workspace', + 'Continuation', + 'Workflow', 'Validation', 'Next' ]) diff --git a/hooks/_runtime/workflow-root-registry.v1.json b/hooks/_runtime/workflow-root-registry.v1.json index 259e42dd..dde0e2d9 100644 --- a/hooks/_runtime/workflow-root-registry.v1.json +++ b/hooks/_runtime/workflow-root-registry.v1.json @@ -11,10 +11,10 @@ }, { "ref": "scripts/lib/host-parity-scorecard.js", - "digest": "7c46243482b4cadf24d9351f96f397f9b16550056d6e6af3bb469a8668de13ba" + "digest": "e3e64094eaed8fd55432d67befc5135ab321c21d59b1f679b65982f8bf585381" } ], - "sourceDigest": "56db449dd3a9287bb0573f30a87303c35efcfa8732aff836094f32b465a45dd7", + "sourceDigest": "920582706c85065731db1bb2f648f09613531b323a48d2089174cfd6e06a7681", "baseBundles": { "chat": [], "resume": [ diff --git a/hooks/_runtime/workflow-root-registry.v2.json b/hooks/_runtime/workflow-root-registry.v2.json index 6b231ade..f126eee8 100644 --- a/hooks/_runtime/workflow-root-registry.v2.json +++ b/hooks/_runtime/workflow-root-registry.v2.json @@ -1112,10 +1112,10 @@ }, { "ref": "scripts/lib/host-parity-scorecard.js", - "digest": "7c46243482b4cadf24d9351f96f397f9b16550056d6e6af3bb469a8668de13ba" + "digest": "e3e64094eaed8fd55432d67befc5135ab321c21d59b1f679b65982f8bf585381" } ], - "sourceDigest": "15ccef360eb51269a4ce56b9712b0b5517132e8c4f3e9b7bc58b561da5c3b31c", + "sourceDigest": "dbe8bd852580b7b56d776dfd876786660077b97c47728e7f4cf521565bfa2cbb", "routeRevision": "82670f3bca8316fcdb62984b9360d40d32a1f7f8671a1be3ddc7f268ff7547c5", - "registryDigest": "e1ca15211d5d20da937b04ddaecb317d8bb7d7fbde27f2b89a668bd07b10532f" + "registryDigest": "3961a16107dfe92e0c46337a4ff5aa1e0ea3ff554f225d09fe378146e44ed329" } diff --git a/hooks/_runtime/workflow-route-decision-v2.cjs b/hooks/_runtime/workflow-route-decision-v2.cjs index 73207ca8..a63aa2d9 100644 --- a/hooks/_runtime/workflow-route-decision-v2.cjs +++ b/hooks/_runtime/workflow-route-decision-v2.cjs @@ -8,7 +8,9 @@ const { const { digest, validateActualInstructionEnvelope, - validateWorkItemSet + validateWorkItemSet, + PROVENANCE_LEVELS, + isTrustedIngressProvenance } = require('./actual-instruction-envelope.cjs') const WORKFLOW_ROUTE_DECISION_SCHEMA = 'WorkflowRouteDecisionV2' @@ -288,9 +290,9 @@ function validateWorkflowRouteDecision (decision, options = {}) { if ((decision.workItemId === null) !== (decision.workItemDigest === null) || (decision.workItemId !== null && (!/^work-[a-f0-9]{40}$/.test(String(decision.workItemId || '')) || !DIGEST_RE.test(String(decision.workItemDigest || ''))))) errors.push('work-item-binding') - if (!['trusted-host-event', 'caller-attested-portable'].includes(decision.provenanceLevel)) errors.push('provenance-level') + if (!PROVENANCE_LEVELS.includes(decision.provenanceLevel)) errors.push('provenance-level') if (!['trusted-host-workflow-ingress', 'portable-plan-only'].includes(decision.authorityScope)) errors.push('authority-scope') - if ((decision.provenanceLevel === 'trusted-host-event') !== + if (isTrustedIngressProvenance(decision.provenanceLevel) !== (decision.authorityScope === 'trusted-host-workflow-ingress')) errors.push('provenance-authority-mismatch') if (decision.mutationAuthority !== false) errors.push('mutation-authority') if (decision.releaseAuthority !== false) errors.push('release-authority') diff --git a/index.js b/index.js index 7e9edd12..655d3f65 100644 --- a/index.js +++ b/index.js @@ -437,7 +437,7 @@ function cmdGrok(argv) { } const cliCommandRegistry = createCliCommandRegistry({ - cmdInitWorkspaceRuntime, cmdInitHost, cmdUninstallHost, cmdGrok, cmdStatus, cmdProfileInit, cmdDoctor, + cmdGrokBot: argv => require('./scripts/lib/grok-bot-driver.js').cmdGrokBot(argv, { console, c, process }), cmdInitWorkspaceRuntime, cmdInitHost, cmdUninstallHost, cmdGrok, cmdStatus, cmdProfileInit, cmdDoctor, cmdProbe, cmdTrace, cmdSkill, cmdTask, cmdGlobalAdapters, cmdRuntime, cmdTemp, cmdGovernance, cmdHelp }) diff --git a/mcp/agent-identity.cjs b/mcp/agent-identity.cjs index 4ede5ca1..055ef520 100644 --- a/mcp/agent-identity.cjs +++ b/mcp/agent-identity.cjs @@ -16,8 +16,15 @@ const VALID_AGENTS = new Set([ 'unknown-agent' ]) +// Host-variant agents that share an existing agent identity (profile/memory +// ownership stays with the base agent; the variant is recorded separately). +const AGENT_ALIASES = Object.freeze({ + 'grok-bot': 'grok' +}) + function normalizeAgent(value) { - const agent = String(value || '').trim().toLowerCase() + const raw = String(value || '').trim().toLowerCase() + const agent = AGENT_ALIASES[raw] || raw return VALID_AGENTS.has(agent) ? agent : '' } diff --git a/mcp/memory-server.js b/mcp/memory-server.js index 5f776e20..d93b43a3 100644 --- a/mcp/memory-server.js +++ b/mcp/memory-server.js @@ -34,6 +34,7 @@ const os = require('os') const path = require('path') const { assertSingleSegment, resolveInside, resolveExistingRegularFileInside, resolveWritePathInside } = require('./path-guard') const { createJsonLineServer } = require('./stdio-jsonrpc.cjs') +const { hostSessionPartitionSegments, wrapDispatchWithHostSession } = require('../hooks/_runtime/host-session-partition.cjs') const { createMemoryFileTransaction } = require('./memory-file-transaction.cjs') const { computeProjectTargetLeaseDigest, @@ -66,7 +67,8 @@ const { validateAutoCheckpointDecision, validateTaskScopedAutoContinuationGrant, writeAdmissionIngressSnapshot, - writeBoundedResumeIngressCapability + writeBoundedResumeIngressCapability, + isTaskRecoveryBindingOrphaned } = require('../hooks/_runtime/task-recovery-store-v5.cjs') const { buildActualInstructionEnvelope, @@ -274,7 +276,19 @@ const EXPLICIT_RUNTIME_AGENT = normalizeAgent(process.env.DEVCODEX_AGENT) const DEFAULT_AGENT = detectRuntimeAgent() const TASK_KINDS = new Set(['requirements', 'bugs', 'optimizations', 'scenario-tests']) const MAX_MEMORY_SESSION_WRITE_CHARS = 262144 -const MEMORY_SESSION_WRITE_REQUIRED_FIELDS = Object.freeze(['content']) +const MEMORY_SESSION_WRITE_REQUIRED_FIELDS = Object.freeze([]) +const MEMORY_SESSION_TURN_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['user', 'ai'], + properties: { + user: { type: 'string' }, ai: { type: 'string' }, + status: { type: 'string', enum: ['in-progress', 'completed', 'blocked'] } + } +}) +const MEMORY_SESSION_STATUS_SCHEMA = Object.freeze({ type: 'string', enum: ['in-progress', 'completed'] }) +const MEMORY_SESSION_STATUS_TEXT = Object.freeze({ 'in-progress': '🔄 进行中', completed: '✅ 已完成' }) +const MEMORY_TURN_STATUS_ICON = Object.freeze({ 'in-progress': '🔄', completed: '✅', blocked: '⛔' }) const MEMORY_SOURCE_MAX_BYTES = 8 * 1024 * 1024 const WORKSPACE_CONTEXT_PROJECT = '__workspace__' const PROJECT_NAMESPACE_INPUT_SCHEMA = Object.freeze({ @@ -733,11 +747,13 @@ const TOOLS = [ inputSchema: { type: 'object', additionalProperties: false, - required: [...MEMORY_SESSION_WRITE_REQUIRED_FIELDS], + anyOf: [{ required: ['content'] }, { required: ['turn'] }, { required: ['sessionStatus'] }], properties: { agent: { type: 'string' }, date: { type: 'string', description: 'YYYYMMDD,默认今日' }, content: { type: 'string', minLength: 1, maxLength: MAX_MEMORY_SESSION_WRITE_CHARS }, + turn: MEMORY_SESSION_TURN_SCHEMA, + sessionStatus: MEMORY_SESSION_STATUS_SCHEMA, artifacts: { type: 'array', minItems: 1, maxItems: 20, items: ARTIFACT_LINK_DESCRIPTOR_SCHEMA }, sessionId: { type: 'string', minLength: 1, maxLength: 64, description: 'allocate 返回的会话编号' }, sessionBinding: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'allocate 返回的绑定值' }, @@ -852,6 +868,25 @@ const TOOLS = [ project: PROJECT_NAMESPACE_INPUT_SCHEMA } } + }, + { + name: 'memory_turn_closeout_v1', + description: 'S05 收尾:报告+会话记忆+SUMMARY。', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['project', 'title', 'intent', 'turn'], + properties: { + project: PROJECT_NAMESPACE_INPUT_SCHEMA, + title: { type: 'string' }, intent: { type: 'string' }, sourceMessage: { type: 'string' }, + turn: MEMORY_SESSION_TURN_SCHEMA, + sessionStatus: MEMORY_SESSION_STATUS_SCHEMA, + notes: { type: 'string' }, + report: { type: 'object', required: ['targetPath', 'content'], properties: { targetPath: { type: 'string' }, content: { type: 'string' } } }, + summary: { type: 'string' }, + terminal: { type: 'object' } + } + } } ] @@ -1692,7 +1727,7 @@ const MEMORY_SESSION_ALLOCATE_FIELDS = new Set([ 'agent', 'scope', 'project', 'date', 'title', 'intent', 'sourceMessage' ]) const MEMORY_SESSION_WRITE_FIELDS = new Set([ - 'agent', 'scope', 'project', 'date', 'content', 'artifacts', 'sessionId', 'sessionBinding' + 'agent', 'scope', 'project', 'date', 'content', 'turn', 'sessionStatus', 'artifacts', 'sessionId', 'sessionBinding' ]) const MEMORY_ARTIFACT_LINK_PROJECT_FIELDS = new Set([ 'operation', 'documentPath', 'artifacts', 'linkCapability', 'scope', 'project' @@ -1908,7 +1943,69 @@ function normalizeMemoryAllocationLine(value, fallback, field, maxLength) { return normalized } -function insertMemorySessionContent(existing, content, binding) { +function sanitizeMemoryTableCell(value) { + return String(value || '').replace(/\r?\n+/g, ' ').replace(/\|/g, '\\|').trim() +} + +function normalizeMemorySessionEdits(args) { + const edits = { turn: null, sessionStatus: null } + if (args.turn !== undefined) { + const turn = args.turn + if (!turn || typeof turn !== 'object' || Array.isArray(turn) || + typeof turn.user !== 'string' || !turn.user.trim() || typeof turn.ai !== 'string' || !turn.ai.trim() || + (turn.status !== undefined && !MEMORY_TURN_STATUS_ICON[turn.status])) { + throw memoryQueryError('turn must be {user, ai, status?}.', 'Pass short user/AI summaries for one table row.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + edits.turn = { user: sanitizeMemoryTableCell(turn.user).slice(0, 400), ai: sanitizeMemoryTableCell(turn.ai).slice(0, 800), status: turn.status || 'completed' } + } + if (args.sessionStatus !== undefined) { + if (!MEMORY_SESSION_STATUS_TEXT[args.sessionStatus]) { + throw memoryQueryError('sessionStatus must be in-progress or completed.', 'Use in-progress until the task is closed.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + edits.sessionStatus = args.sessionStatus + } + return edits +} + +// Applies structured edits inside one bound session block: status line, a numbered 📨 table row, +// and free Markdown separated from any trailing table so it never becomes a malformed table row. +function applyMemorySessionBlockEdits(blockText, content, edits = {}) { + let block = blockText.replace(/\s*$/, '\n') + const applied = [] + if (edits.sessionStatus) { + const statusRe = /^- \*\*状态\*\*:.*$/m + if (statusRe.test(block)) { + block = block.replace(statusRe, `- **状态**:${MEMORY_SESSION_STATUS_TEXT[edits.sessionStatus]}`) + applied.push('status') + } + } + let rowText = null + if (edits.turn) { + const lines = block.split('\n') + const heading = lines.findIndex(line => /^###\s*📨/.test(line)) + if (heading >= 0) { + let first = heading + 1 + while (first < lines.length && !lines[first].startsWith('|')) first++ + let last = first + while (last < lines.length && lines[last].startsWith('|')) last++ + const rows = lines.slice(first, last).filter(line => /^\|\s*\d+\s*\|/.test(line)).length + rowText = `| ${rows + 1} | ${edits.turn.user} | ${edits.turn.ai} | ${MEMORY_TURN_STATUS_ICON[edits.turn.status]} |` + lines.splice(last, 0, rowText) + block = lines.join('\n') + applied.push('turn') + } + } + if (content) { + const trimmed = block.replace(/\s*$/, '') + const lastLine = trimmed.slice(trimmed.lastIndexOf('\n') + 1) + const gap = lastLine.startsWith('|') && !String(content).startsWith('|') ? '\n\n' : '\n' + block = `${trimmed}${gap}${content}${String(content).endsWith('\n') ? '' : '\n'}` + applied.push('content') + } + return { block, applied, rowText } +} + +function insertMemorySessionContent(existing, content, binding, edits = {}) { const blocks = parseDailySessionBlocks(existing) if (!binding.sessionId || !binding.sessionBinding) { throw memoryQueryError( @@ -1948,11 +2045,17 @@ function insertMemorySessionContent(existing, content, binding) { ) } - const targetPrefix = existing.slice(0, target.end).replace(/[ \t]*$/, '') const suffix = existing.slice(target.end) - const separator = targetPrefix.endsWith('\n') ? '' : '\n' - const contentSuffix = String(content).endsWith('\n') ? '' : '\n' - const next = `${targetPrefix}${separator}${content}${contentSuffix}${suffix}` + const blockEdit = applyMemorySessionBlockEdits(existing.slice(target.start, target.end), content, edits) + if (!blockEdit.applied.length) { + throw memoryQueryError( + `Memory session ${target.sessionId} has no status line or 📨 table for the requested edit.`, + 'Pass content, or repair the session block to the memory-session template.', + 'MEMORY_SESSION_LAYOUT_INVALID' + ) + } + const trailing = suffix ? (blockEdit.block.endsWith('\n\n') ? '' : '\n') : '' + const next = `${existing.slice(0, target.start)}${blockEdit.block}${trailing}${suffix}` const afterBlocks = parseDailySessionBlocks(next) const targetAfter = afterBlocks.find(block => block.sessionId === target.sessionId) @@ -1962,7 +2065,9 @@ function insertMemorySessionContent(existing, content, binding) { block.sessionId === nonTargetAfter[index]?.sessionId && block.raw === nonTargetAfter[index]?.raw )) const targetChanged = Boolean(targetAfter && targetAfter.raw !== target.raw) - const targetContainsWrite = Boolean(targetAfter && targetAfter.raw.includes(String(content))) + const targetContainsWrite = Boolean(targetAfter && + (!content || targetAfter.raw.includes(String(content))) && + (!blockEdit.rowText || targetAfter.raw.includes(blockEdit.rowText))) if (!nonTargetStable || !targetChanged || !targetContainsWrite) { throw memoryQueryError( 'Memory session write isolation verification failed before persistence.', @@ -1978,7 +2083,8 @@ function insertMemorySessionContent(existing, content, binding) { mode: 'bound-session', sessionId: target.sessionId, bindingStatus: 'verified', - writeDigest: fileDigest(content), + writeDigest: fileDigest(edits.turn || edits.sessionStatus ? JSON.stringify([content, edits]) : content), + applied: blockEdit.applied, targetBeforeDigest: target.digest, targetAfterDigest: targetAfter.digest, nonTargetStable, @@ -3479,20 +3585,29 @@ function handleMemorySessionWrite(args) { 'memory_session_write', MEMORY_SESSION_WRITE_REQUIRED_FIELDS ) - if (typeof args.content !== 'string' || !args.content.length) { + const hasContent = args.content !== undefined + if (hasContent && (typeof args.content !== 'string' || !args.content.length)) { throw memoryQueryError( 'content must be a non-empty string.', 'Pass bounded Markdown content from the current task.', 'MEMORY_WRITER_ARGUMENT_INVALID' ) } + if (!hasContent && args.turn === undefined && args.sessionStatus === undefined && args.artifacts === undefined) { + throw memoryQueryError( + 'memory_session_write needs content, turn, sessionStatus or artifacts.', + 'Pass a turn row ({user, ai, status}) and/or sessionStatus, or bounded Markdown content.', + 'MEMORY_WRITER_ARGUMENT_INVALID' + ) + } + const sessionEdits = normalizeMemorySessionEdits(args) validateDate(args.date) const target = resolveMemoryTarget(args) const binding = recoverMemorySessionWriteBinding(args, target) const p = memoryClientPath(target, 'tasks', `${args.date || today()}.md`) const documentPath = relativeToActiveRoot(target, p) let artifactLinks = null - let renderedContent = args.content + let renderedContent = hasContent ? args.content : '' if (args.artifacts !== undefined) { artifactLinks = projectMemoryArtifactLinks(target, documentPath, args.artifacts, { surface: 'memory-session-artifacts' @@ -3516,7 +3631,7 @@ function handleMemorySessionWrite(args) { templateContext.selectContent = content => boundMemoryTemplateContent(content, binding) templateContext.artifactScope = () => ({ kind: 'memory-session', sessionId: binding.sessionId }) const receipt = withMemoryTransaction(target, p, existing => { - const rendered = insertMemorySessionContent(existing, renderedContent, binding) + const rendered = insertMemorySessionContent(existing, renderedContent, binding, sessionEdits) sessionWriteReceipt = rendered.receipt return { content: rendered.content, @@ -3529,7 +3644,7 @@ function handleMemorySessionWrite(args) { date: args.date || today(), sessionId: binding.sessionId, sessionBinding: binding.sessionBinding, - contentDigest: crypto.createHash('sha256').update(renderedContent).digest('hex') + contentDigest: crypto.createHash('sha256').update(sessionEdits.turn || sessionEdits.sessionStatus ? JSON.stringify([renderedContent, sessionEdits]) : renderedContent).digest('hex') }), templateContext }) @@ -5184,7 +5299,7 @@ function currentPhysicalProjectRoot(target) { function readServerOwnedLifecycleProjection(target) { const scopeKey = LAYOUT.enabled ? assertSingleSegment(target.project, 'project') : 'legacy' - const relativeStatePath = path.join('.memory', 'hooks', scopeKey, 'lifecycle-state.json') + const relativeStatePath = path.join('.memory', 'hooks', scopeKey, ...hostSessionPartitionSegments(), 'lifecycle-state.json') let statePath try { statePath = resolveExistingRegularFileInside(target.activeRoot, relativeStatePath, { @@ -5427,7 +5542,7 @@ function readServerOwnedAdmissionIngress(target, ingressRef, options = {}) { ) } const scopeKey = LAYOUT.enabled ? assertSingleSegment(target.project, 'project') : 'legacy' - const ingressMetaDir = path.join(target.activeRoot, '.memory', 'hooks', scopeKey) + const ingressMetaDir = path.join(target.activeRoot, '.memory', 'hooks', scopeKey, ...hostSessionPartitionSegments()) // A bounded resume capability is narrower than the immutable host snapshot: // it is additionally bound to the exact finalized task generation and // fenced owner. Prefer it when both share the same ingressRef so relocation @@ -5437,7 +5552,11 @@ function readServerOwnedAdmissionIngress(target, ingressRef, options = {}) { ingressRef: ref, project: target.project, activeRoot: target.activeRoot, - taskId: options.taskId || readServerOwnedLifecycleProjection(target).state?.taskRecoveryBinding?.taskId + taskId: options.taskId || (() => { + // Ignore a recovery binding whose task folder was removed outside DevCodex. + const binding = readServerOwnedLifecycleProjection(target).state?.taskRecoveryBinding + return binding && !isTaskRecoveryBindingOrphaned(binding) ? binding.taskId : undefined + })() }, { fs, nowMs: Date.now(), requireAuthority: true }) if (resumeRead.status === 'fresh' && resumeRead.authority === true) { const candidate = resumeRead.candidate @@ -5643,8 +5762,8 @@ function readServerOwnedAdmissionIngress(target, ingressRef, options = {}) { function workflowRouteIndexMetaDir(target) { return LAYOUT.enabled - ? path.join(LAYOUT.workspaceRoot, '.devcodex', 'workspace', '.memory', 'hooks', 'workspace') - : path.join(target.activeRoot, '.memory', 'hooks', 'legacy') + ? path.join(LAYOUT.workspaceRoot, '.devcodex', 'workspace', '.memory', 'hooks', 'workspace', ...hostSessionPartitionSegments()) + : path.join(target.activeRoot, '.memory', 'hooks', 'legacy', ...hostSessionPartitionSegments()) } function stableTaskIdentityReadback(target, transaction, ingress, taskId) { @@ -6594,6 +6713,9 @@ function handleMemoryTaskAdmitV2(args) { admission.ingressAuthority = admission.ingressAuthority || ingress.authorityReceipt admission.ingressSource = ingressSource admission.ingressRef = admission.ingressRef || currentIngressRef(ingress) + // Surface a caller-supplied ingressRef that was rejected and replaced by the + // server-owned projection, so the fallback is never silent. + admission.ingressWarnings = Array.isArray(ingress.ingressWarnings) ? ingress.ingressWarnings : [] admission.activeVersion = MEMORY_RUNTIME_IDENTITY.activeVersion admission.runtimeGeneration = MEMORY_RUNTIME_IDENTITY.generationId const explicitReopen = args.task.entryVariant === 'reopen' @@ -7199,6 +7321,104 @@ function resolveObservedOperationIngress(target, args) { } } +function structuredResult(result) { + if (result?.structuredContent) return result.structuredContent + try { return JSON.parse(result?.content?.[0]?.text) } catch { return { text: result?.content?.[0]?.text || null } } +} + +function handleMemoryTurnCloseoutV1(args) { + const allowed = new Set(['project', 'title', 'intent', 'sourceMessage', 'turn', 'sessionStatus', 'notes', 'report', 'summary', 'terminal']) + for (const key of Object.keys(args || {})) { + if (!allowed.has(key)) throw memoryQueryError(`Unknown field: ${key}`, 'Remove unsupported fields.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + normalizeMemorySessionEdits({ turn: args.turn, sessionStatus: args.sessionStatus }) + if (args.report !== undefined && (!args.report || typeof args.report.targetPath !== 'string' || typeof args.report.content !== 'string' || !args.report.content)) { + throw memoryQueryError('report must be {targetPath, content}.', 'Pass a new reports/ Markdown path and its content.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + if (args.summary !== undefined && (typeof args.summary !== 'string' || !args.summary.trim())) { + throw memoryQueryError('summary must be a non-empty string.', 'Pass one SUMMARY line.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + const base = { project: args.project, scope: 'project' } + const target = resolveMemoryTarget(base) + const steps = {} + let reportDescriptor = null + if (args.report) { + const relative = String(args.report.targetPath || '').replace(/\\/g, '/').replace(/^\.\//, '') + if (!/\.md$/i.test(relative) || !/(^|\/)reports\//.test(relative)) { + throw memoryQueryError('report.targetPath must be a Markdown file under a reports/ directory.', 'Use /reports//YYYYMMDD/NN--简述.md.', 'MEMORY_TURN_CLOSEOUT_REPORT_PATH_INVALID') + } + // Same authority as memory_workflow_operational_write_lease: current ingress + task-report slot. + const lease = structuredResult(handleMemoryWorkflowOperationalWriteLease({ project: args.project, operation: 'create', targets: [relative] })) + const absolute = path.resolve(target.activeRoot, relative) + if (!absolute.startsWith(path.resolve(target.activeRoot) + path.sep)) { + throw memoryQueryError('report.targetPath escapes the active root.', 'Use an active-root relative path.', 'MEMORY_TURN_CLOSEOUT_REPORT_PATH_INVALID') + } + fs.mkdirSync(path.dirname(absolute), { recursive: true }) + fs.writeFileSync(absolute, args.report.content.endsWith('\n') ? args.report.content : `${args.report.content}\n`, { flag: 'wx' }) + steps.report = { + targetPath: relative, + leaseId: lease.lease?.leaseId || null, + slotId: lease.lease?.slotId || null, + digest: crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex') + } + reportDescriptor = { + id: 'turn-report', + label: path.basename(relative, '.md'), + targetPath: relative, + purpose: '本轮报告' + } + } + const allocation = structuredResult(handleMemorySessionAllocate({ + ...base, title: args.title, intent: args.intent, ...(args.sourceMessage ? { sourceMessage: args.sourceMessage } : {}) + })) + steps.session = { sessionId: allocation.sessionId } + const write = structuredResult(handleMemorySessionWrite({ + ...base, + sessionId: allocation.sessionId, + sessionBinding: allocation.sessionBinding, + turn: args.turn, + sessionStatus: args.sessionStatus || 'in-progress', + ...(args.notes ? { content: args.notes } : {}), + ...(reportDescriptor ? { artifacts: [reportDescriptor] } : {}) + })) + steps.sessionWrite = { file: write.file || null, applied: write.sessionWrite?.applied || [], readbackVerified: write.sessionWrite?.readbackVerified === true } + if (args.summary) { + const summaryResult = handleMemorySummaryAppend({ + ...base, + entry: { + summary: args.summary.trim().slice(0, 400), + status: args.sessionStatus === 'completed' ? 'completed' : 'active', + type: args.intent, + sessionId: allocation.sessionId + }, + ...(reportDescriptor ? { reportArtifact: { label: reportDescriptor.label, targetPath: reportDescriptor.targetPath, purpose: reportDescriptor.purpose } } : {}) + }) + const summary = structuredResult(summaryResult) + steps.summary = { file: summary.file || null, ok: summaryResult?.isError !== true } + } + if (args.terminal) { + if (!steps.report || !steps.sessionWrite?.file) { + throw memoryQueryError('terminal close-out requires this call to write the report and session memory.', 'Provide report and turn in the same call.', 'MEMORY_TASK_CLOSEOUT_EVIDENCE_EMPTY') + } + const closed = handleMemoryTaskCloseoutV1({ + project: args.project, + taskId: args.terminal.taskId, + terminalStatus: args.terminal.terminalStatus, + reportPath: steps.report.targetPath, + memoryPath: steps.sessionWrite.file, + ecr: args.terminal.ecr, + completion: args.terminal.completion + }) + steps.terminal = structuredResult(closed) + if (closed.isError) { + const receipt = { schemaVersion: 'MemoryTurnCloseoutReceiptV1', project: target.project, ...steps } + return { content: [{ type: 'text', text: JSON.stringify(receipt, null, 2) }], structuredContent: receipt, isError: true } + } + } + const receipt = { schemaVersion: 'MemoryTurnCloseoutReceiptV1', project: target.project, ...steps } + return { content: [{ type: 'text', text: JSON.stringify(receipt, null, 2) }], structuredContent: receipt } +} + function handleMemoryWorkflowOperationalWriteLease(args) { const target = taskMemoryTransactionTarget(args) if (target.scope !== 'project' || !target.project) { @@ -7327,6 +7547,107 @@ function handleMemoryTaskTerminalV1(args) { } } +function closeoutEvidenceIdentity(activeRoot, role, relative) { + const bytes = fs.readFileSync(path.join(activeRoot, ...relative.split('/'))) + return { role, path: relative, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length } +} + +function closeoutCell(value) { + return String(value || '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').trim().slice(0, 400) +} + +// Sanctioned producer for the four terminal evidence identities. ECR and the +// completion checklist are rendered from caller-supplied real turn data and the +// digests of the existing report/memory files; terminal CAS fields are read from +// the durable owner so the fenced terminal transition still validates them. +function handleMemoryTaskCloseoutV1(args) { + const allowed = new Set(['project', 'taskId', 'terminalStatus', 'reportPath', 'memoryPath', 'ecr', 'completion']) + for (const key of Object.keys(args || {})) { + if (!allowed.has(key)) throw memoryQueryError(`Unknown field: ${key}`, 'Remove unsupported fields.', 'MEMORY_WRITER_ARGUMENT_INVALID') + } + const checks = Array.isArray(args.ecr?.checks) ? args.ecr.checks : [] + const items = Array.isArray(args.completion?.items) ? args.completion.items : [] + if (!String(args.ecr?.summary || '').trim() || !checks.length || !items.length || + checks.some(c => !String(c?.item || '').trim() || !String(c?.result || '').trim()) || + items.some(c => !String(c?.item || '').trim() || !String(c?.status || '').trim())) { + throw memoryQueryError('ecr.summary, ecr.checks and completion.items must carry real turn data.', 'Describe each executed check and delivered item.', 'MEMORY_TASK_CLOSEOUT_EVIDENCE_EMPTY') + } + const target = resolveMemoryTarget({ project: args.project, scope: 'project' }) + const taskId = String(args.taskId || '').toLowerCase() + const identity = { activeRoot: target.activeRoot, project: target.project, taskId, taskStatus: 'active' } + const ownerRead = readFencedTaskWriteOwner({ metaDir: resolveTaskRecoveryMetaDir(identity), identity }, { fs }) + if (ownerRead.status !== 'fresh' || !ownerRead.owner || !ownerRead.transaction?.taskRootRelative) { + throw memoryQueryError('the task has no durable active owner', 'Admit or bind the task in this turn first.', ownerRead.errorCode || 'TASK_WRITE_OWNER_MISSING') + } + const owner = ownerRead.owner + const taskRootRelative = ownerRead.transaction.taskRootRelative + const norm = value => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '') + const reportPath = norm(args.reportPath) + const memoryPath = norm(args.memoryPath) + if (!reportPath.startsWith(`${taskRootRelative}/reports/`) || !/\.md$/i.test(reportPath)) { + throw memoryQueryError('reportPath must be a Markdown report under the task reports directory.', `Use ${taskRootRelative}/reports//YYYYMMDD/NN--简述.md.`, 'MEMORY_TASK_CLOSEOUT_REPORT_PATH_INVALID') + } + if (!memoryPath.startsWith('.memory/') && !memoryPath.startsWith(`${taskRootRelative}/.memory/`)) { + throw memoryQueryError('memoryPath must be in project or task memory.', 'Use the session file returned by memory_turn_closeout_v1.', 'MEMORY_TASK_CLOSEOUT_MEMORY_PATH_INVALID') + } + const report = closeoutEvidenceIdentity(target.activeRoot, 'report', reportPath) + const memory = closeoutEvidenceIdentity(target.activeRoot, 'memory', memoryPath) + const stamp = new Date().toISOString() + const day = stamp.slice(0, 10).replace(/-/g, '') + const run = `${day}-${stamp.slice(11, 19).replace(/:/g, '')}` + const ecrRelative = `${taskRootRelative}/evidence/closeout/${run}-ECR.md` + const completionRelative = `${taskRootRelative}/evidence/closeout/${run}-完成清单.md` + const evidenceRows = [report, memory].map(e => `| ${e.role} | ${e.path} | ${e.sha256} | ${e.bytes} |`).join('\n') + const ecrContent = [ + `# ECR 执行闭环复审`, '', `- 任务:${taskRootRelative}(${taskId})`, `- 终态:${args.terminalStatus}`, `- 生成:${stamp}(memory_task_closeout_v1)`, '', + `## 结论`, '', closeoutCell(args.ecr.summary), '', + `## 复审项`, '', '| 复审项 | 结果 |', '|---|---|', ...checks.map(c => `| ${closeoutCell(c.item)} | ${closeoutCell(c.result)} |`), '', + `## 引用证据`, '', '| 角色 | 路径 | sha256 | 字节 |', '|---|---|---|---|', evidenceRows, '' + ].join('\n') + const completionContent = [ + `# 完成清单`, '', `- 任务:${taskRootRelative}(${taskId})`, `- 终态:${args.terminalStatus}`, `- 生成:${stamp}(memory_task_closeout_v1)`, '', + '| 交付项 | 状态 |', '|---|---|', ...items.map(c => `| ${closeoutCell(c.item)} | ${closeoutCell(c.status)} |`), '', + `ECR:${ecrRelative}`, '' + ].join('\n') + const lease = structuredResult(handleMemoryWorkflowOperationalWriteLease({ project: args.project, operation: 'create', targets: [ecrRelative, completionRelative] })) + for (const [relative, content] of [[ecrRelative, ecrContent], [completionRelative, completionContent]]) { + const absolute = path.join(target.activeRoot, ...relative.split('/')) + fs.mkdirSync(path.dirname(absolute), { recursive: true }) + fs.writeFileSync(absolute, content, { flag: 'wx' }) + } + const evidence = [ + closeoutEvidenceIdentity(target.activeRoot, 'ecr', ecrRelative), + report, + memory, + closeoutEvidenceIdentity(target.activeRoot, 'completion', completionRelative) + ] + const snapshot = taskOperationTerminalSnapshot(ownerRead.state?.turnLiveness) + const terminal = handleMemoryTaskTerminalV1({ + project: args.project, + taskId, + admissionId: ownerRead.transaction.admissionId, + terminalStatus: args.terminalStatus, + expectedOwner: { ownerGeneration: owner.ownerGeneration, ownerNonce: owner.ownerNonce, leaseRevision: owner.leaseRevision, leaseDigest: owner.leaseDigest }, + lifecycleRevision: Number(owner.reopenGeneration || 0) + 1, + expectedStateSequence: Number(ownerRead.envelope?.sequence || 0), + expectedWriterGeneration: Number(ownerRead.envelope?.writerGeneration ?? owner.ownerGeneration), + settledSetDigest: snapshot.settledSetDigest, + evidence + }) + const result = structuredResult(terminal) + const receipt = { + schemaVersion: 'MemoryTaskCloseoutReceiptV1', + project: target.project, + taskId, + terminalStatus: args.terminalStatus, + leaseId: lease.lease?.leaseId || null, + evidence, + terminal: { status: result.status || null, receiptDigest: result.receipt?.receiptDigest || null, routeReconciliationRequired: result.routeReconciliationRequired === true }, + receipt: result.receipt || null + } + return { content: [{ type: 'text', text: JSON.stringify(receipt, null, 2) }], structuredContent: receipt, isError: terminal.isError === true } +} + function handleMemoryTaskCloseoutReconcileV1(args) { const target = taskMemoryTransactionTarget(args) if (target.scope !== 'project' || !target.project) { @@ -7399,6 +7720,7 @@ function dispatch(method, params) { case 'memory_session_allocate': return handleMemorySessionAllocate(args) case 'memory_session_read': return handleMemorySessionRead(args) case 'memory_session_write': return handleMemorySessionWrite(args) + case 'memory_turn_closeout_v1': return handleMemoryTurnCloseoutV1(args) case 'memory_artifact_link_project': return handleMemoryArtifactLinkProject(args) case 'memory_cp_confirm': return handleMemoryCpConfirm(args) case 'memory_summary_read': return handleMemorySummaryRead(args) @@ -7445,7 +7767,7 @@ function dispatch(method, params) { } if (require.main === module) { - createJsonLineServer({ dispatch, onEnd: () => process.exit(0) }) + createJsonLineServer({ dispatch: wrapDispatchWithHostSession(dispatch), onEnd: () => process.exit(0) }) process.on('SIGINT', () => process.exit(0)) process.on('SIGTERM', () => process.exit(0)) } diff --git a/mcp/profile-server.js b/mcp/profile-server.js index 9a7a8684..111acb09 100644 --- a/mcp/profile-server.js +++ b/mcp/profile-server.js @@ -20,6 +20,7 @@ const path = require('path') const { execFileSync } = require('child_process') const { assertSingleSegment, resolveInside, resolveExistingRegularFileInside, resolveWritePathInside } = require('./path-guard') const { createJsonLineServer } = require('./stdio-jsonrpc.cjs') +const { hostSessionPartitionSegments, wrapDispatchWithHostSession } = require('../hooks/_runtime/host-session-partition.cjs') const { PROFILE_BASE_FILES, PROFILE_RELEASE_FILES, @@ -627,6 +628,35 @@ function resolveProjectBinding(projectName, { requireProfile = true } = {}) { return binding } +function composeEntryWorkspaceEvidence(projectName, entry = {}) { + let root = null + try { + const binding = resolveProjectBinding(projectName, { requireProfile: false }) + root = binding?.physicalRoot || (!LAYOUT.enabled ? resolveProjectRoot(projectName) : null) + } catch {} + if (!root) return null + let branch + let changed + try { + const gitOpts = { cwd: root, encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, windowsHide: true } + branch = execFileSync('git', ['branch', '--show-current'], gitOpts).trim() || 'detached' + changed = execFileSync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], gitOpts) + .split('\n').filter(Boolean).length + } catch { + return null + } + const activeTask = typeof entry.activeTask === 'string' && entry.activeTask.trim() + ? entry.activeTask.trim().slice(0, 120) + : null + const taskZh = activeTask ? `活动任务=${activeTask}` : '活动任务=未提供(以 memory_status/任务准入为准)' + const taskEn = activeTask ? `active task=${activeTask}` : 'active task=not supplied (memory_status/admission is authoritative)' + return { + status: changed > 0 ? 'WARN' : 'PASS', + zh: `Git:分支=${branch};未提交改动=${changed}${changed > 0 ? '(保留用户改动,仅在本轮范围内修改)' : ''};${taskZh}`, + en: `Git: branch=${branch}; uncommitted changes=${changed}${changed > 0 ? ' (preserve user changes; edit only within scope)' : ''}; ${taskEn}` + } +} + function readPackageVersionAt(root) { try { const parsed = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) @@ -1751,7 +1781,7 @@ function verifyContextPlanSemanticSource(value, target, contextEpoch) { for (const candidate of candidates) { try { const file = resolveExistingRegularFileInside(candidate.root, - path.posix.join('.memory', 'hooks', candidate.key, 'lifecycle-state.json')) + path.posix.join('.memory', 'hooks', candidate.key, ...hostSessionPartitionSegments(), 'lifecycle-state.json')) const observed = readBoundedTextFileSync(file, { maxBytes: 2 * 1024 * 1024 }) const state = JSON.parse(observed.content) const acquisition = state.contextAcquisition || {} @@ -2905,14 +2935,22 @@ function dispatch(method, params) { case 'profile_skill_plan': return handleProfileSkillPlan(args) case 'profile_get_mode': return handleProfileGetMode(args) case 'profile_compose_entry_check': { - const { composeEntryCheckBlock } = require('../scripts/lib/host-parity-scorecard.js') + const { composeEntryCheckBlock, composeDriverHostEvidence } = require('../scripts/lib/host-parity-scorecard.js') const entry = args.entry && typeof args.entry === 'object' && !Array.isArray(args.entry) ? args.entry : {} const resolvedConfig = resolveConfigFile(args.project) const projectBound = args.project === 'workspace' ? false : resolvedConfig.projectConfig !== null + // Without an explicit model languageDecision, render in the user prompt's script: a prompt + // written mainly in CJK selects zh-CN for this turn (entry-check rendering only). + const promptText = typeof entry.prompt === 'string' ? entry.prompt : '' + const cjkChars = (promptText.match(/[\u4e00-\u9fff]/g) || []).length + const latinLetters = (promptText.match(/[A-Za-z]/g) || []).length + const promptLanguageDecision = !entry.languageDecision && cjkChars > 0 && cjkChars * 2 >= latinLetters + ? { kind: 'infer', scope: 'turn', replyLocale: 'zh-CN' } + : null const languageContext = resolveLanguageContext({ - languageDecision: entry.languageDecision, + languageDecision: entry.languageDecision || promptLanguageDecision || undefined, prompt: entry.prompt, taskContext: entry.languageContext || entry.taskLanguageContext, conversationContext: entry.conversationLanguageContext, @@ -2957,6 +2995,10 @@ function dispatch(method, params) { semanticDigest: args.semanticDigest, entryCheckModel, languageContext, + envMode: typeof resolvedConfig.config?.mode === 'string' ? resolvedConfig.config.mode : undefined, + specRadar: entry.specRadar, + hostEvidence: composeDriverHostEvidence(), + workspaceEvidence: args.project === 'workspace' ? null : composeEntryWorkspaceEvidence(args.project, entry), audience: 'human' }) return { @@ -3019,7 +3061,7 @@ function dispatch(method, params) { // ─── bounded stdio transport ────────────────────────────────────────────────── if (require.main === module) { - createJsonLineServer({ dispatch, onEnd: () => process.exit(0) }) + createJsonLineServer({ dispatch: wrapDispatchWithHostSession(dispatch), onEnd: () => process.exit(0) }) process.on('SIGINT', () => process.exit(0)) process.on('SIGTERM', () => process.exit(0)) } diff --git a/package.json b/package.json index 5092050f..496b7902 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:candidate-review-bundle": "node scripts/test-candidate-review-bundle.js", "test:actual-candidate-evidence": "node scripts/test-actual-candidate-evidence.js", "test:capability-surface-decision": "node scripts/test-capability-surface-decision.js", + "test:grok-bot-host": "node scripts/test-grok-bot-host.js", "test:host-capability-routing": "node scripts/test-host-capability-routing.js", "test:requirement-parallel-orchestration": "node scripts/test-requirement-parallel-orchestration.js", "test:evidence-freshness": "node scripts/test-evidence-freshness-receipt.js", @@ -485,6 +486,8 @@ "scripts/lib/node-runtime-readiness.js", "scripts/lib/runtime-dependency-closure.js", "scripts/lib/grok-workspace-launcher.js", + "scripts/lib/grok-bot-driver.js", + "scripts/lib/devcodex-source-profile-truth.js", "scripts/lib/grok-cli-env.js", "scripts/lib/cli-host-utils.js", "scripts/lib/cli-console-utils.js", diff --git a/scripts/grok-bot-runtime.sh b/scripts/grok-bot-runtime.sh new file mode 100755 index 00000000..7de023fe --- /dev/null +++ b/scripts/grok-bot-runtime.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Pin Grok Bot's DevCodex connectors and driver to a clean runtime copy, separate from the dev checkout. +# scripts/grok-bot-runtime.sh update [runtimeDir] [branch] clone if missing, then fast-forward only +# scripts/grok-bot-runtime.sh restart [runtimeDir] stop the runtime's profile/memory servers (host respawns them) +# scripts/grok-bot-runtime.sh status [runtimeDir] +# Defaults: runtimeDir=/workspace/devcodex-runtime, branch=current upstream branch of the runtime (or main for a new clone). +set -euo pipefail +cmd="${1:-status}" +RUNTIME="${2:-${DEVCODEX_GROK_BOT_RUNTIME:-/workspace/devcodex-runtime}}" +REMOTE_URL="${DEVCODEX_GROK_BOT_REMOTE:-https://github.com/devcodex-labs/devcodex.git}" +servers() { pgrep -f "node ${RUNTIME}/mcp/(profile|memory)-server\.js" || true; } +case "$cmd" in + update) + BRANCH="${3:-}" + if [ ! -d "$RUNTIME/.git" ]; then + git clone -q --branch "${BRANCH:-main}" "$REMOTE_URL" "$RUNTIME" + else + if [ -n "$(git -C "$RUNTIME" status --porcelain)" ]; then + echo "runtime copy $RUNTIME has local changes; refusing to update" >&2; exit 2 + fi + git -C "$RUNTIME" fetch -q origin + if [ -n "$BRANCH" ] && [ "$(git -C "$RUNTIME" rev-parse --abbrev-ref HEAD)" != "$BRANCH" ]; then + git -C "$RUNTIME" checkout -q "$BRANCH" + fi + git -C "$RUNTIME" merge -q --ff-only "@{upstream}" + fi + echo "runtime $(git -C "$RUNTIME" rev-parse --abbrev-ref HEAD)@$(git -C "$RUNTIME" rev-parse --short=8 HEAD); run '$0 restart' to load it" + ;; + restart) + pids="$(servers)" + if [ -n "$pids" ]; then kill $pids; echo "stopped: $pids (the host restarts connectors on next call)"; else echo "no running runtime servers"; fi + ;; + status) + echo "runtime $(git -C "$RUNTIME" rev-parse --abbrev-ref HEAD 2>/dev/null)@$(git -C "$RUNTIME" rev-parse --short=8 HEAD 2>/dev/null)" + echo "servers: $(servers | tr '\n' ' ')" + ;; + *) echo "usage: $0 update|restart|status [runtimeDir] [branch]" >&2; exit 64 ;; +esac diff --git a/scripts/lib/cli-command-registry.js b/scripts/lib/cli-command-registry.js index afdd23df..3f17f0ca 100644 --- a/scripts/lib/cli-command-registry.js +++ b/scripts/lib/cli-command-registry.js @@ -140,6 +140,7 @@ function runCliCommand({ cmd, argv, registry, runMigrateLayout, process, c, cons if (cmd === 'tmp') { registry.cmdTemp(argv); return 'tmp' } if (cmd === 'governance') { registry.cmdGovernance(argv); return 'governance' } if (cmd === 'grok') { registry.cmdGrok(argv); return 'grok' } + if (cmd === 'grok-bot' && typeof registry.cmdGrokBot === 'function') { registry.cmdGrokBot(argv); return 'grok-bot' } if (cmd === 'global-adapters') { registry.cmdGlobalAdapters(argv) return 'global-adapters' diff --git a/scripts/lib/cli-profile-materialization.js b/scripts/lib/cli-profile-materialization.js index c21f4e6b..04922e5d 100644 --- a/scripts/lib/cli-profile-materialization.js +++ b/scripts/lib/cli-profile-materialization.js @@ -33,11 +33,13 @@ function materializeMissingProfile(input = {}) { const context = input.context || {} const runtimeOptions = input.runtimeOptions || {} const generators = input.generators || {} - const contents = Object.fromEntries( + const generated = Object.fromEntries( (Array.isArray(input.actions) ? input.actions : []) .filter(item => item.action === 'generate') .map(item => [item.file, generators[item.file]()]) ) + const contents = require('./devcodex-source-profile-truth.js') + .augmentDevCodexSourceProfileContents(generated, input.projectRoot, runtimeOptions.sourceTruth || {}) return materializeProfileDraft({ projectIdentity: runtimeOptions.projectIdentity || path.basename(input.projectRoot), projectRoot: input.projectRoot, diff --git a/scripts/lib/devcodex-source-profile-truth.js b/scripts/lib/devcodex-source-profile-truth.js new file mode 100644 index 00000000..99b96bea --- /dev/null +++ b/scripts/lib/devcodex-source-profile-truth.js @@ -0,0 +1,147 @@ +'use strict' + +// ProfileCurrentTruthV1 for the DevCodex source repository itself. +// scripts/validate-profile.js enforces ProfileCurrentTruthGate when the target +// is the DevCodex package root. A generated draft therefore needs a truth record +// built from *observed* facts: package.json, local git HEAD, the local +// validation candidate identity, CI matrix from the workflow, and public GitHub +// release/workflow-run receipts. Nothing is assumed: missing evidence stays +// UNVERIFIED and the unchanged validator rejects it. + +const fs = require('fs') +const path = require('path') +const { execFileSync, spawnSync } = require('child_process') +const { + PROFILE_CURRENT_TRUTH_REF, + extractWorkflowCurrentTruth +} = require('./profile-current-truth') + +const PACKAGE_ROOT = path.resolve(__dirname, '..', '..') +const DEFAULT_REPO = 'devcodex-labs/devcodex' + +function isDevCodexSourceRoot (projectRoot) { + return path.resolve(projectRoot || '') === PACKAGE_ROOT +} + +function isoNow () { + return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +function gitHead (root) { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + } catch { return '' } +} + +const FETCH_SCRIPT = ` +const [repo, tag] = process.argv.slice(1) +const headers = { 'user-agent': 'devcodex-profile-truth', accept: 'application/vnd.github+json' } +if (process.env.GITHUB_TOKEN) headers.authorization = 'Bearer ' + process.env.GITHUB_TOKEN +const get = async url => { const r = await fetch(url, { headers }); if (!r.ok) throw new Error(url + ' ' + r.status); return r.json() } +;(async () => { + const out = {} + try { out.release = await get('https://api.github.com/repos/' + repo + '/releases/tags/' + tag) } catch (e) { out.releaseError = e.message } + try { + const runs = await get('https://api.github.com/repos/' + repo + '/actions/workflows/publish.yml/runs?per_page=30') + out.publish = (runs.workflow_runs || []).find(r => r.head_branch === tag && r.event === 'push') || null + } catch (e) { out.publishError = e.message } + if (out.publish && out.publish.head_sha) { + try { + const runs = await get('https://api.github.com/repos/' + repo + '/actions/workflows/ci.yml/runs?per_page=10&head_sha=' + out.publish.head_sha) + out.ci = (runs.workflow_runs || [])[0] || null + } catch (e) { out.ciError = e.message } + } + process.stdout.write(JSON.stringify(out)) +})() +` + +function fetchReleaseEvidence (repo, tag, options = {}) { + if (options.evidence) return options.evidence + const result = spawnSync(process.execPath, ['-e', FETCH_SCRIPT, repo, tag], { + encoding: 'utf8', timeout: options.timeoutMs || 20000, env: process.env + }) + try { return JSON.parse(result.stdout || '{}') } catch { return {} } +} + +function observed (identityField, identity, status, at) { + return { [identityField]: String(identity || 'unobserved'), status, observedAt: at } +} + +function runStatus (run) { + if (!run) return 'UNVERIFIED' + if (run.status !== 'completed') return 'UNVERIFIED' + return run.conclusion === 'success' ? 'PASS' : 'BLOCK' +} + +function buildDevCodexSourceTruthRecord (projectRoot, options = {}) { + const root = path.resolve(projectRoot) + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) + const version = pkg.version + const tag = `v${version}` + const head = options.gitHead || gitHead(root) + const now = options.now || isoNow() + let candidateId = options.candidateId || '' + if (!candidateId) { + try { candidateId = require('./validation-dag').buildCandidateIdentity({ repoRoot: root }).candidateId } catch { } + } + const evidence = fetchReleaseEvidence(options.repo || DEFAULT_REPO, tag, options) + const release = evidence.release || null + const publish = evidence.publish || null + const ci = evidence.ci || null + const releaseCommit = /^[0-9a-f]{40}$/i.test(String(publish?.head_sha || '')) ? publish.head_sha : head + const readText = file => { try { return fs.readFileSync(path.join(root, file), 'utf8') } catch { return '' } } + let manifest = null + try { manifest = JSON.parse(readText('scripts/validation-manifest.json') || 'null') } catch { } + return { + schemaVersion: 'ProfileCurrentTruthV1', + sourceVersion: version, + releaseState: 'working-source (generated by devcodex profile init from observed release receipts)', + npmLatest: version, + gitHead: head, + releaseCommit, + sourceCandidate: { + schemaVersion: 'SourceCandidateTruthV1', + candidateId, + status: 'LOCAL_PENDING', + localQualification: observed('runId', 'not-run', 'UNVERIFIED', now), + remoteCi: { ...observed('runId', 'not-run', 'UNVERIFIED', now), head }, + releaseAuthorized: false + }, + ciRun: { ...observed('id', ci?.id, runStatus(ci), now), ...(ci?.head_sha ? { head: ci.head_sha } : {}) }, + publishRun: { ...observed('id', publish?.id, runStatus(publish), now), ...(publish?.head_sha ? { head: publish.head_sha } : {}) }, + githubRelease: observed('tag', release?.tag_name || tag, release && !release.draft ? 'PASS' : 'UNVERIFIED', now), + ciMatrix: extractWorkflowCurrentTruth(readText('.github/workflows/ci.yml'), manifest), + asOf: now + } +} + +function truthSection (record) { + return ['## ProfileCurrentTruthV1', '```json', JSON.stringify(record, null, 2), '```', ''].join('\n') +} + +function withTruthRef (text, title) { + const body = String(text || `# ${title}\n`) + if (body.includes(PROFILE_CURRENT_TRUTH_REF)) return body + return `${body.replace(/\s*$/, '\n')}\n> 当前发布真相:${PROFILE_CURRENT_TRUTH_REF}\n` +} + +/** Add the release-truth carrier and references required for the DevCodex source Profile. */ +function augmentDevCodexSourceProfileContents (contents, projectRoot, options = {}) { + if (!isDevCodexSourceRoot(projectRoot)) return contents + const next = { ...contents } + const record = buildDevCodexSourceTruthRecord(projectRoot, options) + const release = String(next['05-发布规范.md'] || '# 05 发布规范\n') + next['05-发布规范.md'] = /^## ProfileCurrentTruthV1/m.test(release) + ? release + : `${release.replace(/\s*$/, '\n')}\n${truthSection(record)}` + next['01-项目信息.md'] = withTruthRef(next['01-项目信息.md'], '01 项目信息') + next['04-测试规范.md'] = withTruthRef(next['04-测试规范.md'], '04 测试规范') + next['07-用户文档与契约规范.md'] = withTruthRef(next['07-用户文档与契约规范.md'], '07 用户文档与契约规范') + return next +} + +module.exports = { + augmentDevCodexSourceProfileContents, + buildDevCodexSourceTruthRecord, + isDevCodexSourceRoot +} diff --git a/scripts/lib/docs-surface-inventory.js b/scripts/lib/docs-surface-inventory.js index c7bb8a28..6140c7a6 100644 --- a/scripts/lib/docs-surface-inventory.js +++ b/scripts/lib/docs-surface-inventory.js @@ -47,6 +47,7 @@ const REQUIRED_MCP_TOOLS = Object.freeze([ 'memory_task_resolve', 'memory_task_terminal_v1', 'memory_task_write_owner', + 'memory_turn_closeout_v1', 'memory_workflow_operational_write_lease', 'profile_compose_entry_check', 'profile_context_plan', diff --git a/scripts/lib/grok-bot-driver.js b/scripts/lib/grok-bot-driver.js new file mode 100644 index 00000000..d77c03bb --- /dev/null +++ b/scripts/lib/grok-bot-driver.js @@ -0,0 +1,518 @@ +'use strict' + +// DevCodex grok-bot driver. +// Grok Bot (desktop assistant) has no native lifecycle hook events. This driver +// synthesizes SessionStart/UserPromptSubmit and bracketing Pre/PostToolUse +// events through hooks/_runtime/lifecycle-host-adapters.cjs with the +// `grok-bot` driver host. Every event is recorded as driver-synthesized; the +// driver never claims native host provenance and cannot enforce PreToolUse. + +const { spawn, spawnSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const PACKAGE_ROOT = path.resolve(__dirname, '..', '..') +const ADAPTER = path.join(PACKAGE_ROOT, 'hooks', '_runtime', 'lifecycle-host-adapters.cjs') +const DRIVER_HOST = 'grok-bot' +const DEFAULT_SESSION_ID = 'grok-bot-session' +const { isValidHostSessionId } = require('../../hooks/_runtime/host-session-partition.cjs') +const SERVER_PREFIX = { profile: 'devcodex-profile', memory: 'devcodex-memory' } + +function driverError (code, message) { + const error = new Error(message) + error.code = code + return error +} + +// Session precedence: explicit --session-id / options.sessionId, then DEVCODEX_HOST_SESSION_ID, +// then the single-conversation default. Each Grok Bot conversation must use its own id: +// lifecycle/recovery/admission/route state is partitioned per session id. +function resolveSessionId (options = {}, env = process.env) { + const value = String(options.sessionId || env.DEVCODEX_HOST_SESSION_ID || DEFAULT_SESSION_ID).trim() + if (!isValidHostSessionId(value)) { + throw driverError('GROK_BOT_SESSION_ID_INVALID', `session id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}: ${value}`) + } + return value +} + +function driverEnv (env = process.env, sessionId) { + return { + ...env, + DEVCODEX_HOST_SESSION_ID: sessionId || env.DEVCODEX_HOST_SESSION_ID || DEFAULT_SESSION_ID, + DEVCODEX_HOST_PLATFORM: env.DEVCODEX_HOST_PLATFORM || 'grok', + DEVCODEX_AGENT: env.DEVCODEX_AGENT || 'grok-bot' + } +} + +function runHookEvent (projectDir, event, extra = {}, options = {}) { + const env = driverEnv(options.env, resolveSessionId(options, options.env)) + const payload = { + hook_event_name: event, + session_id: env.DEVCODEX_HOST_SESSION_ID, + cwd: projectDir, + ...extra + } + const result = spawnSync(process.execPath, [ADAPTER, DRIVER_HOST], { + cwd: projectDir, + env, + input: JSON.stringify(payload), + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: options.timeoutMs || 120000 + }) + if (result.status !== 0) { + throw driverError('GROK_BOT_HOOK_FAILED', `${event} exited ${result.status}: ${String(result.stderr || '').trim().slice(0, 2000)}`) + } + try { + return JSON.parse(result.stdout || '{}') + } catch { + throw driverError('GROK_BOT_HOOK_OUTPUT_INVALID', `${event} produced non-JSON output`) + } +} + +function extractBlocks (output) { + const text = typeof output === 'string' + ? output + : [output?.systemMessage, output?.hookSpecificOutput?.additionalContext, output?.additionalContext] + .filter(value => typeof value === 'string').join('\n') + const blocks = {} + for (const line of String(text || '').split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) continue + try { + const value = JSON.parse(trimmed) + if (value && typeof value.schemaVersion === 'string') blocks[value.schemaVersion] = value + } catch { } + } + const epoch = /contextEpoch=(ctx-[A-Za-z0-9-]+)/.exec(text || '') + if (epoch) blocks.contextEpoch = epoch[1] + return blocks +} + +function ingressRefFrom (ingress) { + if (!ingress) return null + if (ingress.admissionRef) return ingress.admissionRef + if (!ingress.envelopeId || !ingress.envelopeDigest || !ingress.decisionDigest || !ingress.routeRevision) return null + return { + schemaVersion: 'WorkflowIngressProjectionRefV1', + envelopeId: ingress.envelopeId, + envelopeDigest: ingress.envelopeDigest, + decisionDigest: ingress.decisionDigest, + routeRevision: ingress.routeRevision + } +} + +function summarizeIngress (output) { + const blocks = extractBlocks(output) + const ingress = blocks.WorkflowIngressProjectionV1 || null + const bootstrap = blocks.SkillRouteBootstrapV1 || null + return { + contextEpoch: blocks.contextEpoch || bootstrap?.contextEpoch || null, + turnBinding: bootstrap?.turnBinding || null, + envelopeId: ingress?.envelopeId || null, + envelopeDigest: ingress?.envelopeDigest || null, + routeStatus: ingress?.routeStatus || null, + decisionDigest: ingress?.decisionDigest || null, + routeRevision: ingress?.routeRevision || null, + ingressRef: ingressRefFrom(ingress), + ingress, + skillRouteBootstrap: bootstrap + } +} + +const PROFILE_STALE_COMMIT_THRESHOLD = 20 + +function gitOut (cwd, args) { + try { + return require('child_process').execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() + } catch { return null } +} + +// Bounded, read-only freshness check of the project's workspace Profile against the project's Git state. +function profileFreshness (root) { + let ws = path.dirname(root) + while (ws && !fs.existsSync(path.join(ws, '.devcodex', 'layout.json'))) { + const parent = path.dirname(ws) + if (parent === ws) { ws = null; break } + ws = parent + } + if (!ws) return { status: 'unknown', warnings: [] } + const profileDir = path.join(ws, '.devcodex', path.relative(ws, root), 'profile') + if (!fs.existsSync(profileDir)) { + return { status: 'missing', profileDir, warnings: [{ code: 'PROFILE_MISSING', message: `no Profile at ${profileDir}; run devcodex profile init in the project` }] } + } + const warnings = [] + const head = gitOut(root, ['rev-parse', 'HEAD']) + let newest = 0 + for (const name of fs.readdirSync(profileDir)) { + const file = path.join(profileDir, name) + let stat + try { stat = fs.statSync(file) } catch { continue } + if (!stat.isFile()) continue + newest = Math.max(newest, stat.mtimeMs) + if (!/\.md$/.test(name) || stat.size > 512 * 1024) continue + const match = fs.readFileSync(file, 'utf8').match(/"schemaVersion":\s*"ProfileCurrentTruthV1"[\s\S]*?"gitHead":\s*"([a-f0-9]{7,40})"/) + if (match && head && !head.startsWith(match[1])) { + const behind = gitOut(root, ['rev-list', '--count', `${match[1]}..HEAD`]) + warnings.push({ code: 'PROFILE_TRUTH_DRIFT', file: name, profileGitHead: match[1].slice(0, 8), gitHead: head.slice(0, 8), commitsBehind: behind === null ? null : Number(behind), message: 'Profile current-truth gitHead differs from the project HEAD; refresh with devcodex profile init --force or scripts/refresh-profile-current-truth.js' }) + } + } + if (head && newest) { + const since = gitOut(root, ['rev-list', '--count', `--since=@${Math.floor(newest / 1000)}`, 'HEAD']) + if (since !== null && Number(since) >= PROFILE_STALE_COMMIT_THRESHOLD) { + warnings.push({ code: 'PROFILE_POSSIBLY_STALE', commitsSinceProfileUpdate: Number(since), message: `${since} commits since the Profile was last updated; review the Profile before relying on it` }) + } + } + return { status: warnings.length ? 'stale' : 'fresh', profileDir, warnings } +} + +function turnBegin (projectDir, prompt, options = {}) { + const root = path.resolve(projectDir) + if (!fs.existsSync(root)) throw driverError('GROK_BOT_PROJECT_MISSING', `project directory not found: ${root}`) + if (!String(prompt || '').trim()) throw driverError('GROK_BOT_PROMPT_REQUIRED', 'prompt is required') + runHookEvent(root, 'SessionStart', {}, options) + const output = runHookEvent(root, 'UserPromptSubmit', { prompt }, options) + const summary = summarizeIngress(output) + const freshness = profileFreshness(root) + return { + schemaVersion: 'GrokBotTurnBeginV1', + profileFreshness: freshness, + warnings: freshness.warnings.map(item => `${item.code}: ${item.message}`), + provenance: 'driver-synthesized', + project: options.project || path.basename(root), + projectDir: root, + sessionId: driverEnv(options.env, resolveSessionId(options, options.env)).DEVCODEX_HOST_SESSION_ID, + ...summary, + semanticDecisionSourceRef: summary.envelopeId + ? { envelopeId: summary.envelopeId, envelopeDigest: summary.envelopeDigest, contextEpoch: summary.contextEpoch } + : null, + systemMessage: options.includeSystemMessage ? output.systemMessage || '' : undefined, + nextSteps: [ + 'Bracket every devcodex MCP call with observe-tool --phase pre / --phase post (same --tool-use-id and --args-file; post adds --response-file).', + 'profile_compose_entry_check {project,status,nextStep,entry{prompt,specRadar,activeTask}} and show PC0-PC10 first.', + 'profile_context_plan {project,intent,changeTypes,contextEpoch,confidence,semanticDecision{sourceRef=semanticDecisionSourceRef}}', + 'Take ingressRef from the post observe-tool output of profile_context_plan (routeStatus=selected).', + 'profile_load {project,contextBinding,files=plan.profile.selectedFiles}; memory_status {project,contextBinding}', + 'skill_route catalog (paginate until target card seen) -> commit {catalogDigest,skillId,contextBinding}', + 'memory_task_admit_v2 {operation:"admit",project,task{taskKind,entryVariant,displayName},overview{content}} (omit ingressRef)', + 'memory_turn_closeout_v1 {project,title,intent,turn{user,ai,status},sessionStatus,report?,summary?} before ending the turn (S05)' + ] + } +} + +function qualifiedToolName (tool) { + const value = String(tool || '').trim() + if (!value) throw driverError('GROK_BOT_TOOL_REQUIRED', '--tool is required') + if (value.includes('__')) return value + // Only bare DevCodex tool names are namespaced; native host tools (Write, Edit, Bash, ...) pass through. + if (!/^(profile_|memory_|skill_route$)/.test(value)) return value + const memory = /^memory_/.test(value) + return `${memory ? SERVER_PREFIX.memory : SERVER_PREFIX.profile}__${value}` +} + +function observeTool (projectDir, { phase, tool, input, response, toolUseId } = {}, options = {}) { + const root = path.resolve(projectDir) + if (!['pre', 'post'].includes(phase)) throw driverError('GROK_BOT_PHASE_INVALID', '--phase must be pre or post') + if (!toolUseId) throw driverError('GROK_BOT_TOOL_USE_ID_REQUIRED', '--tool-use-id is required') + const event = phase === 'pre' ? 'PreToolUse' : 'PostToolUse' + const toolName = qualifiedToolName(tool) + const sessionId = resolveSessionId(options, options.env) + const toolInput = input || {} + // A connector call names its session through args.hostSession; it must be the bracket's own session. + if (/^devcodex-(?:profile|memory)__/.test(toolName)) { + if (toolInput.hostSession !== undefined && toolInput.hostSession !== sessionId) { + throw driverError('GROK_BOT_HOST_SESSION_MISMATCH', `args.hostSession=${toolInput.hostSession} does not match the bracket session ${sessionId}`) + } + if (options.sessionId && toolInput.hostSession === undefined) { + throw driverError('GROK_BOT_HOST_SESSION_REQUIRED', `--session-id ${sessionId} requires the connector call args to carry hostSession: "${sessionId}"`) + } + } + const extra = { tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId } + if (phase === 'post') extra.tool_response = response === undefined ? null : response + const output = runHookEvent(root, event, extra, options) + const summary = summarizeIngress(output) + return { + schemaVersion: 'GrokBotObserveToolV1', + provenance: 'driver-synthesized', + phase, + tool: extra.tool_name, + toolUseId, + enforced: false, + hostDecision: output?.hookSpecificOutput?.permissionDecision || output?.decision || null, + continue: output?.continue !== false, + routeStatus: summary.routeStatus, + decisionDigest: summary.decisionDigest, + routeRevision: summary.routeRevision, + ingressRef: summary.ingressRef, + systemMessage: typeof output?.systemMessage === 'string' ? output.systemMessage : '' + } +} + +function startMcpServer (kind, projectDir, options = {}) { + const child = spawn(process.execPath, [path.join(PACKAGE_ROOT, 'mcp', `${kind}-server.js`), projectDir], { + cwd: projectDir, env: driverEnv(options.env, resolveSessionId(options, options.env)), stdio: ['pipe', 'pipe', 'ignore'] + }) + let buffer = '' + let nextId = 0 + const pending = new Map() + child.stdout.on('data', chunk => { + buffer += chunk + let index + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + let message + try { message = JSON.parse(line) } catch { continue } + const resolve = pending.get(message.id) + if (resolve) { pending.delete(message.id); resolve(message) } + } + }) + const request = (method, params) => new Promise(resolve => { + const id = ++nextId + pending.set(id, resolve) + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') + }) + return { + kind, + async init () { + await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'devcodex-grok-bot-driver', version: '1' } }) + child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n') + }, + async call (name, args) { + const message = await request('tools/call', { name, arguments: args }) + const result = message.result || {} + let body = result.structuredContent + if (!body) { + try { body = JSON.parse(result.content?.[0]?.text) } catch { body = { text: result.content?.[0]?.text } } + } + return { isError: Boolean(result.isError || message.error), body: body || message.error, raw: result } + }, + close () { child.kill() } + } +} + +function defaultSemanticDecision (sourceRef, overrides = {}) { + return { + schemaVersion: 'IntentSemanticDecisionV1', + sourceRef, + workflowPreference: { ceremonyTier: 'simple', designDepth: 'minimal', assuranceLevel: 'targeted' }, + workflowFacts: { targetKnown: true }, + executionDecision: 'confirm', + ...overrides + } +} + +// Full-turn mode: turn-begin, then own MCP servers bracketed by observe-tool. +async function runTurn (projectDir, prompt, options = {}) { + const root = path.resolve(projectDir) + const project = options.project || path.basename(root) + const intent = options.intent || 'dev' + const changeTypes = options.changeTypes || ['source-code'] + const steps = [] + const step = (name, value) => { steps.push({ step: name, ...value }); if (options.onStep) options.onStep(name, value) } + const begin = turnBegin(root, prompt, { ...options, project }) + step('turn-begin', { routeStatus: begin.routeStatus, contextEpoch: begin.contextEpoch, turnBinding: begin.turnBinding }) + const servers = { profile: startMcpServer('profile', root, options), memory: startMcpServer('memory', root, options) } + let counter = 0 + let lastPost = null + const tracked = async (kind, tool, args) => { + if (options.sessionId) args = { ...args, hostSession: resolveSessionId(options, options.env) } + const toolUseId = `grok-bot-${process.pid}-${Date.now()}-${++counter}` + const name = `${SERVER_PREFIX[kind]}__${tool}` + observeTool(root, { phase: 'pre', tool: name, input: args, toolUseId }, options) + const result = await servers[kind].call(tool, args) + lastPost = observeTool(root, { phase: 'post', tool: name, input: args, response: result.raw, toolUseId }, options) + return result + } + const out = { schemaVersion: 'GrokBotRunV1', provenance: 'driver-synthesized', project, begin, steps } + try { + await servers.profile.init() + await servers.memory.init() + const semanticDecision = defaultSemanticDecision(begin.semanticDecisionSourceRef, options.semanticDecision) + const plan = await tracked('profile', 'profile_context_plan', { + project, intent, changeTypes, contextEpoch: begin.contextEpoch, confidence: options.confidence ?? 0.9, semanticDecision + }) + step('profile_context_plan', { ok: !plan.isError, errorCode: plan.body?.errorCode || null }) + if (plan.isError) throw driverError('GROK_BOT_CONTEXT_PLAN_FAILED', JSON.stringify(plan.body).slice(0, 1000)) + const contextBinding = plan.body.contextBinding + out.contextBinding = contextBinding + out.ingressRef = lastPost.ingressRef + step('ingress-routed', { routeStatus: lastPost.routeStatus, decisionDigest: lastPost.decisionDigest }) + const load = await tracked('profile', 'profile_load', { project, contextBinding, files: plan.body.profile?.selectedFiles || [] }) + step('profile_load', { ok: !load.isError, errorCode: load.body?.errorCode || null }) + for (const query of plan.body.memory?.requiredQueries || []) { + const res = await tracked('memory', query, { project, contextBinding }) + step(query, { ok: !res.isError, errorCode: res.body?.errorCode || null }) + } + const boot = begin.skillRouteBootstrap + if (boot && options.skill) { + let cursor + let page + const seen = [] + do { + page = await tracked('profile', 'skill_route', { op: 'catalog', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, ...(cursor ? { cursor } : {}) }) + seen.push(...(page.body?.receipt?.cards || []).map(card => card.skillId)) + cursor = page.body?.receipt?.nextCursor + } while (cursor && page.body?.ok && !seen.includes(options.skill)) + step('skill_route.catalog', { ok: page.body?.ok === true, seen: seen.length }) + const commit = await tracked('profile', 'skill_route', { + op: 'commit', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, + catalogDigest: page.body?.receipt?.catalogDigest, skillId: options.skill, contextBinding + }) + step('skill_route.commit', { ok: commit.body?.ok === true, errorCode: commit.body?.errorCode || null }) + out.skillCommit = commit.body + } + if (options.admit) { + const admission = await tracked('memory', 'memory_task_admit_v2', { + operation: 'admit', project, ingressRef: out.ingressRef, + task: { + taskKind: options.taskKind || (intent === 'fix' ? 'bugs' : 'requirements'), + entryVariant: options.entryVariant || (intent === 'fix' ? 'fix' : 'new'), + displayName: String(options.displayName || prompt).slice(0, 80) + }, + overview: { content: prompt } + }) + step('memory_task_admit_v2', { ok: !admission.isError, errorCode: admission.body?.errorCode || null, taskId: admission.body?.taskId || null }) + out.admission = admission.body + } + } finally { + servers.profile.close() + servers.memory.close() + } + out.ok = steps.every(item => item.ok !== false) + return out +} + +function parseFlags (argv) { + const positional = [] + const flags = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg.startsWith('--')) { + const eq = arg.indexOf('=') + if (eq > 0) { flags[arg.slice(2, eq)] = arg.slice(eq + 1); continue } + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) flags[arg.slice(2)] = true + else { flags[arg.slice(2)] = next; i++ } + } else positional.push(arg) + } + return { positional, flags } +} + +const HOST_WRAPPER_RE = /^\s*]*>\s*|\s*<\/cursor_untrusted_data_[A-Za-z0-9_]+>\s*$/g + +// Grok Bot surfaces MCP results as text (optionally wrapped in an untrusted-data +// fence). Rebuild the MCP CallToolResult shape the lifecycle observer expects. +function shapeToolResponse (rawText) { + const text = String(rawText || '').replace(HOST_WRAPPER_RE, '').trim() + try { + const parsed = JSON.parse(text) + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.content)) return parsed + } catch { } + const start = text.search(/[{[]/) + let body = null + let head = text + let tail = '' + if (start === 0) { + try { body = JSON.parse(text) } catch { + const match = /^[{[][\s\S]*?\n[}\]]/.exec(text) + if (match) { + try { body = JSON.parse(match[0]); head = match[0]; tail = text.slice(match[0].length).trim() } catch { } + } + } + } + const content = [{ type: 'text', text: head }] + if (tail) content.push({ type: 'text', text: tail }) + const result = { content } + if (body && typeof body === 'object' && !Array.isArray(body)) { + if (body.errorCode || body.ok === false) result.isError = true + } + return result +} + +function readFileFlag (value, name) { + if (value === undefined || value === true) return undefined + try { return fs.readFileSync(String(value), 'utf8') } catch (error) { + throw driverError('GROK_BOT_FILE_UNREADABLE', `--${name}: ${error.message}`) + } +} + +function parseJsonFlag (value, name) { + if (value === undefined || value === true) return undefined + const text = String(value).startsWith('@') ? fs.readFileSync(String(value).slice(1), 'utf8') : String(value) + try { return JSON.parse(text) } catch (error) { throw driverError('GROK_BOT_JSON_INVALID', `--${name}: ${error.message}`) } +} + +const USAGE = [ + 'devcodex grok-bot turn-begin [--project ] [--session-id ] [--system-message]', + 'devcodex grok-bot observe-tool --project-dir [--session-id ] --phase pre|post --tool --tool-use-id (--args-file | --input ) [--response-file | --response ]', + 'Workspace: run `devcodex init` once in the PARENT workspace directory (e.g. /workspace), not inside a repo, then `devcodex profile init` in each project.', + 'Session: one id per conversation via --session-id (or DEVCODEX_HOST_SESSION_ID; default grok-bot-session). Pass the same id on turn-begin and every observe-tool, and as args.hostSession on every connector call; state is partitioned per session.', + 'devcodex grok-bot run [--intent dev|fix|...] [--change source-code,...] [--skill ] [--admit] [--task-kind ] [--entry ]' +].join('\n') + +async function runGrokBotCli (argv = [], io = {}) { + const stdout = io.stdout || process.stdout + const [sub, ...rest] = argv + const { positional, flags } = parseFlags(rest) + let result + if (sub === 'turn-begin') { + result = turnBegin(positional[0] || process.cwd(), positional.slice(1).join(' '), { + project: flags.project, includeSystemMessage: flags['system-message'] === true, + sessionId: typeof flags['session-id'] === 'string' ? flags['session-id'] : undefined + }) + } else if (sub === 'observe-tool') { + if (!flags['project-dir'] || flags['project-dir'] === true) { + throw driverError('GROK_BOT_PROJECT_DIR_REQUIRED', '--project-dir is required for observe-tool') + } + const argsText = readFileFlag(flags['args-file'], 'args-file') + const responseText = readFileFlag(flags['response-file'], 'response-file') + result = observeTool(flags['project-dir'], { + phase: flags.phase, + tool: flags.tool, + input: argsText !== undefined ? parseJsonFlag(argsText, 'args-file') : (parseJsonFlag(flags.input, 'input') || {}), + response: responseText !== undefined ? shapeToolResponse(responseText) : parseJsonFlag(flags.response, 'response'), + toolUseId: flags['tool-use-id'] + }, { sessionId: typeof flags['session-id'] === 'string' ? flags['session-id'] : undefined }) + } else if (sub === 'run') { + result = await runTurn(positional[0] || process.cwd(), positional.slice(1).join(' '), { + project: flags.project, + intent: flags.intent, + changeTypes: typeof flags.change === 'string' ? flags.change.split(',') : undefined, + skill: typeof flags.skill === 'string' ? flags.skill : undefined, + admit: flags.admit === true, + taskKind: flags['task-kind'], + entryVariant: flags.entry, + sessionId: typeof flags['session-id'] === 'string' ? flags['session-id'] : undefined + }) + } else { + throw driverError('GROK_BOT_SUBCOMMAND_INVALID', `Unknown grok-bot subcommand: ${sub || '(none)'}\n${USAGE}`) + } + stdout.write(JSON.stringify(result, null, 2) + '\n') + return result +} + +function cmdGrokBot (argv, { console: out = console, c = { red: s => s }, process: proc = process } = {}) { + return runGrokBotCli(argv).catch(error => { + out.error(c.red(` ${error.code || 'GROK_BOT_DRIVER_FAILED'}: ${error.message}`)) + proc.exitCode = 2 + return null + }) +} + +module.exports = { + profileFreshness, + cmdGrokBot, + DRIVER_HOST, + USAGE, + extractBlocks, + ingressRefFrom, + observeTool, + qualifiedToolName, + runGrokBotCli, + runHookEvent, + runTurn, + shapeToolResponse, + summarizeIngress, + turnBegin +} diff --git a/scripts/lib/host-parity-scorecard.js b/scripts/lib/host-parity-scorecard.js index ac3e6662..e1a692d9 100644 --- a/scripts/lib/host-parity-scorecard.js +++ b/scripts/lib/host-parity-scorecard.js @@ -373,6 +373,12 @@ function composePc4Line(options = {}) { const mode = normalizeEnvMode(options.envMode || options.mode || options.profileMode) const locale = resolveVisibleLocale(options.languageContext) const english = locale.renderedLanguage === 'en' + const radar = typeof options.specRadar === 'string' ? options.specRadar.replace(/[|\r\n]+/g, ' ').trim().slice(0, 400) : '' + if (mode === 'dev' && radar) { + return english + ? `- PC4 [PASS] dev mode radar: ${radar}` + : `- PC4 [PASS] dev 模式规范雷达:${radar}` + } if (mode === 'dev') { return english ? '- PC4 [UNVERIFIED] dev mode: show the full specification radar and bind the selected Skills/Profile/Owner/TestRoute' @@ -552,6 +558,36 @@ function evaluateGrokHostParity(input = {}) { return scorecard } +const PC_STATUS_VALUES = new Set(['PASS', 'WARN', 'FAIL', 'UNVERIFIED', 'N/A']) + +function sanitizeEvidenceCell(value) { + return String(value || '').replace(/[|\r\n]+/g, ' ').trim().slice(0, 400) +} + +function evidenceRow(evidence, english, fallbackText) { + if (!evidence || typeof evidence !== 'object') return { status: 'UNVERIFIED', text: fallbackText } + const status = PC_STATUS_VALUES.has(evidence.status) ? evidence.status : 'UNVERIFIED' + const text = sanitizeEvidenceCell(english ? (evidence.en || evidence.zh) : (evidence.zh || evidence.en)) + return { status, text: text || fallbackText } +} + +/** + * PC5 host evidence for driver-synthesized hosts. Returns null for hosts whose + * PC5 verdict still requires launcher/doctor evidence (kept UNVERIFIED). + */ +function composeDriverHostEvidence(options = {}) { + const env = options.env || process.env + const rawAgent = String(options.agent || env.DEVCODEX_AGENT || '').trim().toLowerCase() + const variant = String(options.hostVariant || env.DEVCODEX_HOST_VARIANT || '').trim() + if (rawAgent !== 'grok-bot' && !variant.startsWith('grok-bot/')) return null + return { + status: 'WARN', + tier: 'grok-bot-driver', + zh: 'Grok Bot 驱动档(Partial 类):本连接器以 DEVCODEX_AGENT=grok-bot 运行;生命周期事件由 devcodex grok-bot turn-begin/observe-tool 合成,无上下文注入,PreToolUse 不强制;不可声称 Full', + en: 'Grok Bot driver tier (Partial-like): connector runs with DEVCODEX_AGENT=grok-bot; lifecycle events are synthesized by devcodex grok-bot turn-begin/observe-tool; no context inject; PreToolUse not enforced; never claim Full' + } +} + function composeEntryCheckBlock(options = {}) { const locale = resolveVisibleLocale(options.languageContext) const english = locale.renderedLanguage === 'en' @@ -628,8 +664,14 @@ function composeEntryCheckBlock(options = {}) { const pc4Status = pc4Match ? pc4Match[1] : 'UNVERIFIED' const pc4Cell = pc4Match ? pc4Match[2] : pc4Line const labels = english - ? ['Interruption', 'Intent', 'Project', 'Boundary', 'Spec radar', 'Scope', 'Dirty boundary', 'Context', 'Risk', 'Validation', 'Next'] - : ['中断处理', '当前意图', '项目绑定', '确认边界', '规范雷达', '本轮范围', 'Dirty 边界', '上下文', '风险等级', '验证', '下一步'] + ? ['Version', 'Intent', 'Session', 'Readiness', 'Spec radar', 'Host', 'Workspace', 'Continuation', 'Workflow', 'Validation', 'Next'] + : ['版本与运行', '当前意图', '会话', '执行准备', '规范雷达', '宿主', '工作区', '续接', '流程与方案', '验证', '下一步'] + const pc5 = evidenceRow(options.hostEvidence, english, english + ? 'Host: deployment, synchronization, and load evidence (Grok is Full only through the Full launcher)' + : '宿主:部署、同步与加载证据(Grok 仅在 Full 启动器下可判 Full)') + const pc6 = evidenceRow(options.workspaceEvidence, english, english + ? 'Workspace: Git change state and active task' + : '工作区:Git 改动状态与当前活动任务') const pcRow = (ordinal, status, text) => `| PC${ordinal} ${labels[ordinal]} | \`${status}\` | ${text} |` const zhRows = [ pcRow(0, version.alignment === 'runtime-mismatch' ? 'WARN' : 'PASS', `版本与运行代际:installed=${version.installedPackageVersion};active=${runtime};configured=${configuredRuntime};source=${source};alignment=${version.alignment};需要重启=${restartRequired};原因=${version.restartReason}`), @@ -637,8 +679,8 @@ function composeEntryCheckBlock(options = {}) { pcRow(2, 'PASS', '会话:轮次、Token 防护与待跟进项'), pcRow(3, 'PASS', '执行准备:唯一项目、连续性与产物落点'), pcRow(4, pc4Status, pc4Cell), - pcRow(5, 'UNVERIFIED', '宿主:部署、同步与加载证据(Grok 仅在 Full 启动器下可判 Full)'), - pcRow(6, 'UNVERIFIED', '工作区:Git 改动状态与当前活动任务'), + pcRow(5, pc5.status, pc5.text), + pcRow(6, pc6.status, pc6.text), pcRow(7, 'PASS', '续接:新会话或 resume 的有界检测'), pcRow(8, 'PASS', `流程与方案:初判=${precheck.ceremonyTier}/${precheck.designDepth};二次=${post};差异=${differences}`), pcRow(9, 'PASS', `验证:${validation.assuranceLevel};定向=${validation.targetedCount},受影响=${validation.affectedCount},全量=${validation.fullCount};CI=${validation.ciRequired},打包=${validation.packageRequired},安装=${validation.installRequired},发布=${validation.releaseRequired};预计时长=${validation.estimatedDuration}`), @@ -650,8 +692,8 @@ function composeEntryCheckBlock(options = {}) { pcRow(2, 'PASS', 'Session: turn count, token guard, and follow-ups'), pcRow(3, 'PASS', 'Execution readiness: unique project, continuity, and artifact destinations'), pcRow(4, pc4Status, pc4Cell), - pcRow(5, 'UNVERIFIED', 'Host: deployment, synchronization, and load evidence (Grok is Full only through the Full launcher)'), - pcRow(6, 'UNVERIFIED', 'Workspace: Git change state and active task'), + pcRow(5, pc5.status, pc5.text), + pcRow(6, pc6.status, pc6.text), pcRow(7, 'PASS', 'Continuation: bounded detection for a new session or resume'), pcRow(8, 'PASS', `Workflow and design: precheck=${precheck.ceremonyTier}/${precheck.designDepth}; reassessment=${post}; differences=${differences}`), pcRow(9, 'PASS', `Validation: ${validation.assuranceLevel}; targeted=${validation.targetedCount}, affected=${validation.affectedCount}, full=${validation.fullCount}; CI=${validation.ciRequired}, package=${validation.packageRequired}, install=${validation.installRequired}, release=${validation.releaseRequired}; estimate=${validation.estimatedDuration}`), @@ -687,6 +729,7 @@ function entryCheckAssistSuffix(options = {}) { } module.exports = { + composeDriverHostEvidence, GROK_TURN_EXECUTION_CHECKLIST, GROK_INTENT_SKILL_BUNDLES, MIN_CANNOT_CLAIM, diff --git a/scripts/test-grok-bot-host.js b/scripts/test-grok-bot-host.js new file mode 100644 index 00000000..c345f4db --- /dev/null +++ b/scripts/test-grok-bot-host.js @@ -0,0 +1,467 @@ +#!/usr/bin/env node +'use strict' + +// grok-bot host: identity registration + split-mode end-to-end through the CLI +// (turn-begin, independently spawned MCP servers bracketed by observe-tool, +// through memory_task_admit_v2). + +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawn, spawnSync } = require('child_process') + +const ROOT = path.resolve(__dirname, '..') +const CLI = path.join(ROOT, 'index.js') +const identity = require('../hooks/_runtime/host-adapter-identity.cjs') +const { normalizeAgent } = require('../mcp/agent-identity.cjs') +const adapters = require('../hooks/_runtime/lifecycle-host-adapters.cjs') +const capabilities = require('../hooks/_runtime/host-skill-route-capabilities.v1.json') + +function staticChecks () { + assert.strictEqual(identity.HOST_VARIANTS.grokbot, 'grok-bot/driver-synthesized-local-stdio') + assert.strictEqual(identity.normalizeHostId('grok-bot'), 'grokbot') + assert.strictEqual(identity.normalizeHostVariant('grok-bot'), 'grok-bot/driver-synthesized-local-stdio') + assert.strictEqual(normalizeAgent('grok-bot'), 'grok') + assert.ok(adapters.DRIVER_HOSTS['grok-bot']) + const env = {} + const driven = adapters.resolveDriverHost('grok-bot', { hook_event_name: 'SessionStart' }, env) + assert.strictEqual(driven.host, 'grok') + assert.strictEqual(driven.payload.devcodexEventProvenance, 'driver-synthesized') + assert.strictEqual(env.DEVCODEX_AGENT, 'grok-bot') + const entry = capabilities.capabilities.find(item => item.hostVariant === 'grok-bot/driver-synthesized-local-stdio') + assert.ok(entry && entry.status === 'UNVERIFIED' && entry.defaultEligible === false) + assert.strictEqual(driven.payload.devcodexExecutionSurface, 'grok-bot-driver') + assert.strictEqual(env.DEVCODEX_HOST_VARIANT, 'grok-bot/driver-synthesized-local-stdio') + const driver = require('./lib/grok-bot-driver.js') + assert.strictEqual(driver.qualifiedToolName('profile_load'), 'devcodex-profile__profile_load') + assert.strictEqual(driver.qualifiedToolName('memory_status'), 'devcodex-memory__memory_status') + assert.strictEqual(driver.qualifiedToolName('skill_route'), 'devcodex-profile__skill_route') + assert.strictEqual(driver.qualifiedToolName('Write'), 'Write') + const scorecard = require('./lib/host-parity-scorecard.js') + assert.strictEqual(scorecard.composeDriverHostEvidence({ env: {} }), null) + assert.strictEqual(scorecard.composeDriverHostEvidence({ env: { DEVCODEX_AGENT: 'grok-bot' } }).status, 'WARN') + const legacy = scorecard.composeEntryCheckBlock({ project: 'p' }) + assert.match(legacy, /\| PC5 [^|]*\| `UNVERIFIED` \|/) + assert.match(legacy, /\| PC6 [^|]*\| `UNVERIFIED` \|/) + const aie = require('../hooks/_runtime/actual-instruction-envelope.cjs') + const mk = hostVariant => aie.buildActualInstructionEnvelope({ hook_event_name: 'UserPromptSubmit', prompt: 'fix it', session_id: 's1', turn_id: 't1', event_id: 'e1' }, { hostVariant, hostSessionId: 's1', trustedHostEvent: true, contextEpoch: 'ctx-1' }) + const driverEnvelope = mk('grok-bot/driver-synthesized-local-stdio') + const nativeEnvelope = mk('codex/desktop') + if (driverEnvelope && nativeEnvelope) { + assert.strictEqual(driverEnvelope.provenanceLevel, 'driver-synthesized-host-event') + assert.strictEqual(nativeEnvelope.provenanceLevel, 'trusted-host-event') + assert.strictEqual(aie.isTrustedHostIngressEnvelope(driverEnvelope), true) + assert.strictEqual(aie.validateActualInstructionEnvelope(driverEnvelope).valid, true) + const forged = { ...nativeEnvelope, provenanceLevel: 'driver-synthesized-host-event' } + assert.ok(aie.validateActualInstructionEnvelope(forged).errors.includes('driver-provenance-host-variant')) + assert.strictEqual(aie.isTrustedHostIngressEnvelope(forged), false) + } + assert.strictEqual(aie.isTrustedHostIngressEnvelope({ provenanceLevel: 'driver-synthesized-host-event', hostVariant: 'codex/desktop' }), false) + console.log(' ✓ grok-bot identity / adapter / capability registration') +} + +function responseShapingChecks () { + const { shapeToolResponse } = require('./lib/grok-bot-driver.js') + const wrapped = shapeToolResponse('\n\n{\n "schemaVersion": "X",\n "ok": true\n}\ntrailing guidance\n') + assert.deepStrictEqual(wrapped.content.map(item => item.text), ['{\n "schemaVersion": "X",\n "ok": true\n}', 'trailing guidance']) + assert.strictEqual(wrapped.isError, undefined) + assert.strictEqual(shapeToolResponse('{"errorCode":"E"}').isError, true) + assert.strictEqual(shapeToolResponse(' text').content[0].text, ' text') + const passthrough = { content: [{ type: 'text', text: 'x' }], isError: true } + assert.deepStrictEqual(shapeToolResponse(JSON.stringify(passthrough)), passthrough) + console.log(' ✓ observe-tool response shaping strips host wrapper') +} + +function sourceProfileTruthChecks () { + const truth = require('./lib/devcodex-source-profile-truth.js') + assert.strictEqual(truth.isDevCodexSourceRoot(ROOT), true) + assert.strictEqual(truth.isDevCodexSourceRoot(os.tmpdir()), false) + const untouched = { 'README.md': 'x' } + assert.strictEqual(truth.augmentDevCodexSourceProfileContents(untouched, os.tmpdir()), untouched) + const sha = 'a'.repeat(40) + const evidence = { + release: { tag_name: 'v' + require('../package.json').version, draft: false }, + publish: { id: 1, head_sha: sha, status: 'completed', conclusion: 'success' }, + ci: { id: 2, head_sha: sha, status: 'completed', conclusion: 'success' } + } + const out = truth.augmentDevCodexSourceProfileContents({ '01-项目信息.md': '# 01\n' }, ROOT, { evidence, candidateId: 'validation-candidate-' + 'b'.repeat(64), gitHead: 'c'.repeat(40) }) + const { parseProfileCurrentTruth, PROFILE_CURRENT_TRUTH_REF } = require('./lib/profile-current-truth') + const parsed = parseProfileCurrentTruth(out['05-发布规范.md'], { required: true }) + assert.deepStrictEqual(parsed.errors, []) + assert.strictEqual(parsed.record.releaseCommit, sha) + assert.strictEqual(parsed.record.sourceCandidate.status, 'LOCAL_PENDING') + assert.strictEqual(parsed.record.sourceCandidate.releaseAuthorized, false) + for (const file of ['01-项目信息.md', '04-测试规范.md', '07-用户文档与契约规范.md']) { + assert.strictEqual(out[file].split(PROFILE_CURRENT_TRUTH_REF).length - 1, 1, file) + } + const missing = truth.buildDevCodexSourceTruthRecord(ROOT, { evidence: {}, candidateId: 'validation-candidate-' + 'b'.repeat(64), gitHead: 'c'.repeat(40) }) + assert.strictEqual(missing.publishRun.status, 'UNVERIFIED') + assert.strictEqual(missing.githubRelease.status, 'UNVERIFIED') + console.log(' ✓ DevCodex source Profile truth is built only from observed evidence') +} + +function cli (args, opts) { + const result = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', maxBuffer: 64 << 20, ...opts }) + if (result.status !== 0) throw new Error(`devcodex ${args.slice(0, 2).join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`) + return result.stdout +} + +function mcp (kind, projectDir, env) { + const child = spawn(process.execPath, [path.join(ROOT, 'mcp', `${kind}-server.js`), projectDir], { cwd: projectDir, env, stdio: ['pipe', 'pipe', 'ignore'] }) + let buf = '' + let id = 0 + const pending = new Map() + child.stdout.on('data', d => { + buf += d + let i + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1) + try { const m = JSON.parse(line); const r = pending.get(m.id); if (r) { pending.delete(m.id); r(m) } } catch { } + } + }) + const req = (method, params) => new Promise(resolve => { const n = ++id; pending.set(n, resolve); child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: n, method, params }) + '\n') }) + return { + init: async () => { await req('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'grok-bot-test', version: '1' } }); child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n') }, + call: async (name, args) => { const m = await req('tools/call', { name, arguments: args }); return m.result || {} }, + close: () => child.kill() + } +} + +function body (result) { + if (result.structuredContent) return result.structuredContent + try { return JSON.parse(result.content?.[0]?.text) } catch { return { text: result.content?.[0]?.text } } +} + +async function splitModeE2E () { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devcodex-grok-bot-')) + const ws = path.join(tmp, 'ws') + const proj = path.join(ws, 'proj') + fs.mkdirSync(proj, { recursive: true }) + fs.mkdirSync(path.join(tmp, 'home')) + spawnSync('git', ['init', '-q'], { cwd: proj }) + fs.writeFileSync(path.join(proj, 'README.md'), '# proj\n') + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'add', '-A'], { cwd: proj }) + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'init'], { cwd: proj }) + const env = { ...process.env, HOME: path.join(tmp, 'home'), USERPROFILE: path.join(tmp, 'home'), DEVCODEX_HOST_SESSION_ID: 'grok-bot-test-session', DEVCODEX_AGENT: 'grok-bot', DEVCODEX_HOST_PLATFORM: 'grok' } + for (const key of ['GROK_HOME', 'CODEX_HOME', 'CLAUDE_CODE_VERSION', 'CURSOR_TRACE_ID']) delete env[key] + cli(['init'], { cwd: ws, env }) + cli(['profile', 'init'], { cwd: proj, env }) + const prompt = '修复登录页面空指针' + const begin = JSON.parse(cli(['grok-bot', 'turn-begin', proj, prompt], { cwd: proj, env })) + assert.strictEqual(begin.provenance, 'driver-synthesized') + assert.match(begin.contextEpoch, /^ctx-/) + assert.match(begin.turnBinding, /^turn-/) + assert.ok(begin.envelopeId && begin.semanticDecisionSourceRef) + assert.strictEqual(begin.routeStatus, 'pending') + assert.strictEqual(begin.skillRouteBootstrap.hostVariant, 'grok-bot/driver-synthesized-local-stdio') + assert.strictEqual(begin.profileFreshness.status, 'fresh', JSON.stringify(begin.profileFreshness)) + const driverLib = require('./lib/grok-bot-driver.js') + const truthFile = path.join(ws, '.devcodex', 'proj', 'profile', '99-truth-fixture.md') + fs.writeFileSync(truthFile, '```json\n{"schemaVersion": "ProfileCurrentTruthV1", "gitHead": "0123456789abcdef0123456789abcdef01234567"}\n```\n') + const drift = driverLib.profileFreshness(proj) + assert.strictEqual(drift.status, 'stale') + assert.strictEqual(drift.warnings[0].code, 'PROFILE_TRUTH_DRIFT') + fs.rmSync(truthFile) + console.log(' ✓ turn-begin reports Profile freshness and detects current-truth drift') + console.log(' ✓ turn-begin yields contextEpoch / turnBinding / envelope') + + const servers = { 'devcodex-profile': mcp('profile', proj, env), 'devcodex-memory': mcp('memory', proj, env) } + await servers['devcodex-profile'].init(); await servers['devcodex-memory'].init() + let n = 0 + const call = async (server, tool, args) => { + const id = `t-${++n}` + const name = `${server}__${tool}` + const argsFile = path.join(tmp, `${id}.args.json`) + const responseFile = path.join(tmp, `${id}.response.txt`) + fs.writeFileSync(argsFile, JSON.stringify(args)) + cli(['grok-bot', 'observe-tool', '--project-dir', proj, '--phase', 'pre', '--tool', name, '--args-file', argsFile, '--tool-use-id', id], { cwd: proj, env }) + const result = await servers[server].call(tool, args) + // Simulate Grok Bot: the host shows only the text content inside an untrusted-data fence. + fs.writeFileSync(responseFile, `\n\n${(result.content || []).map(item => item.text).join('\n')}\n`) + const post = JSON.parse(cli(['grok-bot', 'observe-tool', '--project-dir', proj, '--phase', 'post', '--tool', name, '--args-file', argsFile, '--response-file', responseFile, '--tool-use-id', id], { cwd: proj, env })) + return { result, body: body(result), post } + } + try { + const project = 'proj' + const entryCheck = await call('devcodex-profile', 'profile_compose_entry_check', { + project, status: 'UNVERIFIED', nextStep: 'profile_context_plan', + entry: { prompt, locale: 'en_US.UTF-8', specRadar: 'Skills=fix-default; Profile=01-03', activeTask: 'none' } + }) + assert.ok(!entryCheck.result.isError, JSON.stringify(entryCheck.body).slice(0, 500)) + const block = entryCheck.body.block + for (let i = 0; i <= 10; i++) assert.match(block, new RegExp(`\\| PC${i} `)) + assert.match(block, /\| PC5 宿主 \| `WARN` \| Grok Bot 驱动档/) + assert.doesNotMatch(block, /\| PC6 [^|]*\| `UNVERIFIED`/) + assert.match(block, /\| PC6 工作区 \| `(PASS|WARN)` \| Git:分支=/) + assert.match(block, /### DevCodex · 入口检查/) + console.log(' ✓ profile_compose_entry_check reports grok-bot PC5 tier and observed PC6 git state') + const plan = await call('devcodex-profile', 'profile_context_plan', { + project, intent: 'fix', changeTypes: ['source-code'], contextEpoch: begin.contextEpoch, confidence: 0.9, + semanticDecision: { schemaVersion: 'IntentSemanticDecisionV1', sourceRef: begin.semanticDecisionSourceRef, workflowFacts: { targetKnown: true }, executionDecision: 'confirm' } + }) + assert.ok(!plan.result.isError, JSON.stringify(plan.body).slice(0, 500)) + assert.strictEqual(plan.post.routeStatus, 'selected') + assert.ok(plan.post.ingressRef && plan.post.ingressRef.decisionDigest) + console.log(' ✓ profile_context_plan + post observe-tool routes ingress (selected)') + const contextBinding = plan.body.contextBinding + const load = await call('devcodex-profile', 'profile_load', { project, contextBinding, files: plan.body.profile.selectedFiles }) + assert.ok(!load.result.isError, JSON.stringify(load.body).slice(0, 500)) + for (const q of plan.body.memory?.requiredQueries || []) { + const r = await call('devcodex-memory', q, { project, contextBinding }) + assert.ok(!r.result.isError, `${q}: ${JSON.stringify(r.body).slice(0, 300)}`) + } + const boot = begin.skillRouteBootstrap + let cursor; let page; const seen = [] + do { + page = await call('devcodex-profile', 'skill_route', { op: 'catalog', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, ...(cursor ? { cursor } : {}) }) + assert.strictEqual(page.body.ok, true, JSON.stringify(page.body).slice(0, 300)) + seen.push(...page.body.receipt.cards.map(c => c.skillId)) + cursor = page.body.receipt.nextCursor + } while (cursor && !seen.includes('fix-default')) + const commit = await call('devcodex-profile', 'skill_route', { op: 'commit', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, catalogDigest: page.body.receipt.catalogDigest, skillId: 'fix-default', contextBinding }) + assert.strictEqual(commit.body.ok, true, JSON.stringify(commit.body).slice(0, 300)) + console.log(' ✓ profile_load / memory_status / skill_route catalog+commit') + const admit = await call('devcodex-memory', 'memory_task_admit_v2', { + operation: 'admit', project, + // Malformed caller ref: server falls back to its own projection but must report it. + ingressRef: { ...plan.post.ingressRef, decisionDigest: 'PLACEHOLDER' }, + task: { taskKind: 'bugs', entryVariant: 'fix', displayName: prompt }, overview: { content: prompt } + }) + assert.ok(!admit.result.isError, JSON.stringify(admit.body).slice(0, 500)) + assert.strictEqual(admit.body.schemaVersion, 'FormalTaskAdmissionReceiptV2') + assert.strictEqual(admit.body.status, 'active') + assert.ok(admit.body.ingressWarnings.includes('TASK_ADMISSION_INGRESS_REF_INVALID'), JSON.stringify(admit.body.ingressWarnings)) + const lifecycleState = JSON.parse(fs.readFileSync(path.join(ws, '.devcodex', project, '.memory', 'hooks', project, '.sessions', 'grok-bot-test-session', 'lifecycle-state.json'), 'utf8')) + const envelope = lifecycleState.actualInstructionEnvelope + assert.strictEqual(envelope.hostVariant, 'grok-bot/driver-synthesized-local-stdio') + assert.strictEqual(envelope.provenanceLevel, 'driver-synthesized-host-event') + console.log(' ✓ memory_task_admit_v2 admitted', admit.body.taskRootRelative, 'with driver-synthesized-host-event provenance') + const reportPath = `${admit.body.taskRootRelative}/reports/grok/20260101/01--验证报告.md` + const closeout = await call('devcodex-memory', 'memory_turn_closeout_v1', { + project, title: 'grok-bot e2e', intent: 'fix', sourceMessage: prompt, + turn: { user: prompt, ai: '定位并修复空指针 | 已验证', status: 'completed' }, + sessionStatus: 'in-progress', + notes: '关键决策:仅修改空指针分支', + report: { targetPath: reportPath, content: '# 验证报告\n\n- ok\n' }, + summary: 'grok-bot e2e closeout' + }) + assert.ok(!closeout.result.isError, JSON.stringify(closeout.body).slice(0, 800)) + assert.strictEqual(closeout.body.schemaVersion, 'MemoryTurnCloseoutReceiptV1') + const activeRoot = path.join(ws, '.devcodex', project) + assert.ok(fs.existsSync(path.join(activeRoot, reportPath))) + const daily = fs.readFileSync(path.join(activeRoot, closeout.body.sessionWrite.file), 'utf8') + assert.match(daily, /- \*\*状态\*\*:🔄 进行中/) + assert.doesNotMatch(daily, /待补充内容/) + assert.match(daily, /\| 1 \| 修复登录页面空指针 \| 定位并修复空指针 \\\| 已验证 \| ✅ \|/) + assert.match(daily, /✅ \|\n\n关键决策:仅修改空指针分支/) + assert.ok(closeout.body.summary && closeout.body.summary.ok) + console.log(' ✓ memory_turn_closeout_v1 writes report + session row/status + SUMMARY in one call') + + // Regression: a task folder removed outside DevCodex must not poison the next admission + // that omits ingressRef (stale TaskRecoveryBinding / bounded-resume state for a missing task). + fs.rmSync(path.join(activeRoot, admit.body.taskRootRelative), { recursive: true, force: true }) + { + // Any next tool event drops the orphaned TaskRecoveryBinding instead of rehydrating its ingress. + const statePath = path.join(activeRoot, '.memory', 'hooks', project, '.sessions', 'grok-bot-test-session', 'lifecycle-state.json') + const before = JSON.parse(fs.readFileSync(statePath, 'utf8')) + assert.ok(before.taskRecoveryBinding?.taskId, 'admission binds the task for recovery') + const argsFile = path.join(proj, 'probe-args.json') + fs.writeFileSync(argsFile, JSON.stringify({ project })) + cli(['grok-bot', 'observe-tool', '--phase', 'pre', '--project-dir', proj, '--tool', 'memory_status', '--args-file', argsFile, '--tool-use-id', 'orphan-probe'], { cwd: proj, env }) + const after = JSON.parse(fs.readFileSync(statePath, 'utf8')) + assert.strictEqual(after.taskRecoveryBinding, null, JSON.stringify(after.lastResumeIngressRehydrate)) + assert.strictEqual(after.lastResumeIngressRehydrate?.reasonCode, 'task-root-missing') + const { isTaskRecoveryBindingOrphaned } = require('../hooks/_runtime/task-recovery-store-v5.cjs') + assert.strictEqual(isTaskRecoveryBindingOrphaned({ taskRoot: proj }), false) + assert.strictEqual(isTaskRecoveryBindingOrphaned({ taskRoot: path.join(proj, 'nope') }), true) + assert.strictEqual(isTaskRecoveryBindingOrphaned({ taskId: 'x' }), false) + } + const prompt2 = '修复分页 limit 为 0 的错误' + const begin2 = JSON.parse(cli(['grok-bot', 'turn-begin', proj, prompt2], { cwd: proj, env })) + const plan2 = await call('devcodex-profile', 'profile_context_plan', { + project, intent: 'fix', changeTypes: ['source-code'], contextEpoch: begin2.contextEpoch, confidence: 0.9, + semanticDecision: { schemaVersion: 'IntentSemanticDecisionV1', sourceRef: begin2.semanticDecisionSourceRef, workflowFacts: { targetKnown: true }, executionDecision: 'confirm' } + }) + assert.strictEqual(plan2.post.routeStatus, 'selected', JSON.stringify(plan2.body).slice(0, 300)) + const binding2 = plan2.body.contextBinding + await call('devcodex-profile', 'profile_load', { project, contextBinding: binding2, files: plan2.body.profile.selectedFiles }) + for (const q of plan2.body.memory?.requiredQueries || []) await call('devcodex-memory', q, { project, contextBinding: binding2 }) + const boot2 = begin2.skillRouteBootstrap + let cursor2; let page2; const seen2 = [] + do { + page2 = await call('devcodex-profile', 'skill_route', { op: 'catalog', project, turnBinding: boot2.turnBinding, contextEpoch: boot2.contextEpoch, ...(cursor2 ? { cursor: cursor2 } : {}) }) + seen2.push(...page2.body.receipt.cards.map(c => c.skillId)) + cursor2 = page2.body.receipt.nextCursor + } while (cursor2 && !seen2.includes('fix-default')) + await call('devcodex-profile', 'skill_route', { op: 'commit', project, turnBinding: boot2.turnBinding, contextEpoch: boot2.contextEpoch, catalogDigest: page2.body.receipt.catalogDigest, skillId: 'fix-default', contextBinding: binding2 }) + const admit2 = await call('devcodex-memory', 'memory_task_admit_v2', { + operation: 'admit', project, task: { taskKind: 'bugs', entryVariant: 'fix', displayName: prompt2 }, overview: { content: prompt2 } + }) + assert.ok(!admit2.result.isError, JSON.stringify(admit2.body).slice(0, 800)) + assert.strictEqual(admit2.body.status, 'active') + console.log(' ✓ admission without ingressRef succeeds after the previous task folder was removed') + + // memory_turn_closeout_v1 terminal: sanctioned producer of ECR/completion evidence + fenced terminal. + const terminalArgs = (n, terminalStatus, ecr) => ({ + project, title: `grok-bot terminal ${n}`, intent: 'fix', turn: { user: prompt2, ai: '验证终态收尾', status: 'completed' }, + sessionStatus: 'completed', report: { targetPath: `${admit2.body.taskRootRelative}/reports/grok/20260101/0${n}--收尾.md`, content: '# 收尾\n\n- 验证\n' }, + terminal: { + taskId: admit2.body.taskId, terminalStatus, + ecr: ecr || { summary: '验证终态收尾路径', checks: [{ item: 'grok-bot-host e2e', result: 'PASS' }] }, + completion: { items: [{ item: '终态收尾', status: '已交付' }] } + } + }) + const empty = await call('devcodex-memory', 'memory_turn_closeout_v1', terminalArgs(1, 'cancelled', { summary: '', checks: [] })) + assert.strictEqual(empty.result.isError, true, 'empty ECR data is rejected') + const noCp1 = await call('devcodex-memory', 'memory_turn_closeout_v1', terminalArgs(2, 'completed')) + assert.strictEqual(noCp1.result.isError, true) + assert.match(JSON.stringify(noCp1.body), /CP1|CP_CONFIRMATION/, 'completed still requires a verified CP1') + await new Promise(resolve => setTimeout(resolve, 1100)) + const terminal = await call('devcodex-memory', 'memory_turn_closeout_v1', terminalArgs(3, 'cancelled')) + assert.ok(!terminal.result.isError, JSON.stringify(terminal.body).slice(0, 1200)) + const closed = terminal.body.terminal + assert.strictEqual(closed.schemaVersion, 'MemoryTaskCloseoutReceiptV1') + assert.deepStrictEqual(closed.evidence.map(e => e.role), ['ecr', 'report', 'memory', 'completion']) + const ecrText = fs.readFileSync(path.join(activeRoot, closed.evidence[0].path), 'utf8') + assert.match(ecrText, /grok-bot-host e2e \| PASS/) + assert.ok(ecrText.includes(closed.evidence[1].sha256), 'ECR cites the report digest') + assert.ok(closed.terminal.receiptDigest, JSON.stringify(closed.terminal)) + console.log(' ✓ memory_turn_closeout_v1 terminal produces ECR/completion evidence and terminalizes the task') + } finally { + Object.values(servers).forEach(s => s.close()) + if (!process.env.DEVCODEX_KEEP_TEST_TMP) fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +// Two Grok Bot conversations share one pair of connectors and one project. Each passes its own +// --session-id to the driver and hostSession to every connector call; lifecycle, recovery, +// admission and route state must stay partitioned per session. +async function concurrentSessionsE2E () { + const hsp = require('../hooks/_runtime/host-session-partition.cjs') + assert.deepStrictEqual(hsp.hostSessionPartitionSegments({ DEVCODEX_AGENT: 'codex', DEVCODEX_HOST_SESSION_ID: 'x' }), [], 'native hosts keep the per-project partition') + assert.deepStrictEqual(hsp.hostSessionPartitionSegments({ DEVCODEX_AGENT: 'grok-bot', DEVCODEX_HOST_SESSION_ID: 's1' }), ['.sessions', 's1']) + assert.deepStrictEqual(hsp.hostSessionPartitionSegments({ DEVCODEX_AGENT: 'grok-bot', DEVCODEX_HOST_SESSION_ID: 'grok-bot-session' }), [], 'default session keeps the pre-isolation location') + assert.throws(() => hsp.hostSessionPartitionSegments({ DEVCODEX_AGENT: 'grok-bot', DEVCODEX_HOST_SESSION_ID: '../x' }), /HOST_SESSION|session/i) + const passthrough = () => 'same' + assert.strictEqual(hsp.wrapDispatchWithHostSession(passthrough, { DEVCODEX_AGENT: 'claude' }), passthrough, 'non-driver hosts dispatch unchanged') + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devcodex-grok-bot-sessions-')) + const ws = path.join(tmp, 'ws') + const proj = path.join(ws, 'proj') + fs.mkdirSync(proj, { recursive: true }) + fs.mkdirSync(path.join(tmp, 'home')) + spawnSync('git', ['init', '-q'], { cwd: proj }) + fs.writeFileSync(path.join(proj, 'README.md'), '# proj\n') + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'add', '-A'], { cwd: proj }) + spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'init'], { cwd: proj }) + // Connector process env carries only the shared default; sessions are selected per call. + const env = { ...process.env, HOME: path.join(tmp, 'home'), USERPROFILE: path.join(tmp, 'home'), DEVCODEX_HOST_SESSION_ID: 'grok-bot-session', DEVCODEX_AGENT: 'grok-bot', DEVCODEX_HOST_PLATFORM: 'grok' } + for (const key of ['GROK_HOME', 'CODEX_HOME', 'CLAUDE_CODE_VERSION', 'CURSOR_TRACE_ID']) delete env[key] + cli(['init'], { cwd: ws, env }) + cli(['profile', 'init'], { cwd: proj, env }) + const servers = { 'devcodex-profile': mcp('profile', proj, env), 'devcodex-memory': mcp('memory', proj, env) } + await servers['devcodex-profile'].init(); await servers['devcodex-memory'].init() + const project = 'proj' + const activeRoot = path.join(ws, '.devcodex', project) + const statePath = session => path.join(activeRoot, '.memory', 'hooks', project, '.sessions', session, 'lifecycle-state.json') + const readState = session => JSON.parse(fs.readFileSync(statePath(session), 'utf8')) + let n = 0 + const call = async (session, server, tool, rawArgs) => { + const args = { ...rawArgs, hostSession: session } + const id = `${session}-${++n}` + const argsFile = path.join(tmp, `${id}.args.json`) + const responseFile = path.join(tmp, `${id}.response.txt`) + fs.writeFileSync(argsFile, JSON.stringify(args)) + const name = `${server}__${tool}` + cli(['grok-bot', 'observe-tool', '--session-id', session, '--project-dir', proj, '--phase', 'pre', '--tool', name, '--args-file', argsFile, '--tool-use-id', id], { cwd: proj, env }) + const result = await servers[server].call(tool, args) + fs.writeFileSync(responseFile, (result.content || []).map(item => item.text).join('\n')) + const post = JSON.parse(cli(['grok-bot', 'observe-tool', '--session-id', session, '--project-dir', proj, '--phase', 'post', '--tool', name, '--args-file', argsFile, '--response-file', responseFile, '--tool-use-id', id], { cwd: proj, env })) + return { result, body: body(result), post } + } + const begin = (session, prompt) => JSON.parse(cli(['grok-bot', 'turn-begin', proj, prompt, '--session-id', session], { cwd: proj, env })) + const plan = (session, b) => call(session, 'devcodex-profile', 'profile_context_plan', { + project, intent: 'fix', changeTypes: ['source-code'], contextEpoch: b.contextEpoch, confidence: 0.9, + semanticDecision: { schemaVersion: 'IntentSemanticDecisionV1', sourceRef: b.semanticDecisionSourceRef, workflowFacts: { targetKnown: true }, executionDecision: 'confirm' } + }) + const routeAndAdmit = async (session, b, p, prompt) => { + const binding = p.body.contextBinding + await call(session, 'devcodex-profile', 'profile_load', { project, contextBinding: binding, files: p.body.profile.selectedFiles }) + for (const q of p.body.memory?.requiredQueries || []) await call(session, 'devcodex-memory', q, { project, contextBinding: binding }) + const boot = b.skillRouteBootstrap + let cursor; let page; const seen = [] + do { + page = await call(session, 'devcodex-profile', 'skill_route', { op: 'catalog', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, ...(cursor ? { cursor } : {}) }) + assert.strictEqual(page.body.ok, true, `${session}: ${JSON.stringify(page.body).slice(0, 300)}`) + seen.push(...page.body.receipt.cards.map(c => c.skillId)) + cursor = page.body.receipt.nextCursor + } while (cursor && !seen.includes('fix-default')) + const commit = await call(session, 'devcodex-profile', 'skill_route', { op: 'commit', project, turnBinding: boot.turnBinding, contextEpoch: boot.contextEpoch, catalogDigest: page.body.receipt.catalogDigest, skillId: 'fix-default', contextBinding: binding }) + assert.strictEqual(commit.body.ok, true, `${session}: ${JSON.stringify(commit.body).slice(0, 300)}`) + return call(session, 'devcodex-memory', 'memory_task_admit_v2', { operation: 'admit', project, task: { taskKind: 'bugs', entryVariant: 'fix', displayName: prompt }, overview: { content: prompt } }) + } + try { + const a = 'conv-a'; const b = 'conv-b' + const promptA = '修复会话A的空指针'; const promptB = '修复会话B的越界' + // Interleave: A begins, B begins, A plans, B plans, A admits, B admits. + const beginA = begin(a, promptA) + const beginB = begin(b, promptB) + assert.strictEqual(beginA.sessionId, a); assert.strictEqual(beginB.sessionId, b) + assert.notStrictEqual(beginA.contextEpoch, beginB.contextEpoch) + assert.strictEqual(readState(a).contextAcquisition.contextEpoch, beginA.contextEpoch, 'B turn-begin must not clobber A') + assert.strictEqual(readState(b).contextAcquisition.contextEpoch, beginB.contextEpoch) + assert.ok(!fs.existsSync(path.join(activeRoot, '.memory', 'hooks', project, 'lifecycle-state.json')), 'no shared per-project state for driver sessions') + const planA = await plan(a, beginA) + const planB = await plan(b, beginB) + assert.strictEqual(planA.post.routeStatus, 'selected', JSON.stringify(planA.body).slice(0, 300)) + assert.strictEqual(planB.post.routeStatus, 'selected', JSON.stringify(planB.body).slice(0, 300)) + // A's bound plan is not usable from session B. + const crossLoad = await servers['devcodex-profile'].call('profile_load', { project, contextBinding: planA.body.contextBinding, files: planA.body.profile.selectedFiles, hostSession: b }) + assert.strictEqual(crossLoad.isError, true, 'session B cannot consume session A context binding') + console.log(' ✓ two sessions interleave turn-begin / profile_context_plan on one project without clobbering') + const admitA = await routeAndAdmit(a, beginA, planA, promptA) + const admitB = await routeAndAdmit(b, beginB, planB, promptB) + assert.ok(!admitA.result.isError, JSON.stringify(admitA.body).slice(0, 600)) + assert.ok(!admitB.result.isError, JSON.stringify(admitB.body).slice(0, 600)) + assert.notStrictEqual(admitA.body.taskId, admitB.body.taskId) + assert.strictEqual(readState(a).taskRecoveryBinding?.taskId, admitA.body.taskId) + assert.strictEqual(readState(b).taskRecoveryBinding?.taskId, admitB.body.taskId) + console.log(' ✓ concurrent admissions keep per-session task recovery bindings') + const closeArgs = (taskId, root) => ({ + project, title: 'session close', intent: 'fix', turn: { user: 'x', ai: 'y', status: 'completed' }, sessionStatus: 'completed', + report: { targetPath: `${root}/reports/grok/20260101/01--close.md`, content: '# close\n' }, + terminal: { taskId, terminalStatus: 'cancelled', ecr: { summary: 'session isolation', checks: [{ item: 'isolation', result: 'PASS' }] }, completion: { items: [{ item: 'isolation', status: 'done' }] } } + }) + const cross = await call(b, 'devcodex-memory', 'memory_turn_closeout_v1', closeArgs(admitA.body.taskId, admitA.body.taskRootRelative)) + assert.strictEqual(cross.result.isError, true, 'session B must not terminalize session A task') + assert.match(JSON.stringify(cross.body), /TASK_WRITE_OWNER|TASK_BINDING_REQUIRED|owner/i) + const own = await call(a, 'devcodex-memory', 'memory_turn_closeout_v1', closeArgs(admitA.body.taskId, admitA.body.taskRootRelative)) + assert.ok(!own.result.isError, JSON.stringify(own.body).slice(0, 800)) + assert.strictEqual(readState(b).taskRecoveryBinding?.taskId, admitB.body.taskId, 'closing A leaves B bound') + console.log(' ✓ task recovery / terminal close do not cross sessions') + // Strict identity: invalid hostSession is rejected; driver refuses a bracket/args session mismatch. + const bad = await servers['devcodex-memory'].call('memory_status', { project, hostSession: '../a' }) + assert.strictEqual(bad.isError, true) + assert.match(JSON.stringify(bad), /HOST_SESSION_INVALID/) + const mismatchFile = path.join(tmp, 'mismatch.json') + fs.writeFileSync(mismatchFile, JSON.stringify({ project, hostSession: a })) + const mismatch = spawnSync(process.execPath, [CLI, 'grok-bot', 'observe-tool', '--session-id', b, '--project-dir', proj, '--phase', 'pre', '--tool', 'devcodex-memory__memory_status', '--args-file', mismatchFile, '--tool-use-id', 'mm'], { cwd: proj, env, encoding: 'utf8' }) + assert.match(mismatch.stderr + mismatch.stdout, /GROK_BOT_HOST_SESSION_MISMATCH/) + fs.writeFileSync(mismatchFile, JSON.stringify({ project })) + const missing = spawnSync(process.execPath, [CLI, 'grok-bot', 'observe-tool', '--session-id', b, '--project-dir', proj, '--phase', 'pre', '--tool', 'devcodex-memory__memory_status', '--args-file', mismatchFile, '--tool-use-id', 'mm2'], { cwd: proj, env, encoding: 'utf8' }) + assert.match(missing.stderr + missing.stdout, /GROK_BOT_HOST_SESSION_REQUIRED/) + console.log(' ✓ hostSession is validated per call and must match the driver bracket session') + } finally { + Object.values(servers).forEach(s => s.close()) + if (!process.env.DEVCODEX_KEEP_TEST_TMP) fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +;(async () => { + staticChecks() + responseShapingChecks() + sourceProfileTruthChecks() + await splitModeE2E() + await concurrentSessionsE2E() + console.log('grok-bot host tests passed') +})().catch(error => { console.error(error); process.exit(1) }) diff --git a/scripts/test-host-parity-scorecard.js b/scripts/test-host-parity-scorecard.js index 0b47ec0b..633ecd43 100644 --- a/scripts/test-host-parity-scorecard.js +++ b/scripts/test-host-parity-scorecard.js @@ -41,7 +41,7 @@ const block = composeEntryCheckBlock({ assert.match(block, /### DevCodex · 入口检查/) assert.match(block, /`PASS` · `demo`/) assert.match(block, /\| 检查项 \| 状态 \| 结论 \|/) -assert.match(block, /PC0 中断处理 \| `PASS`/) +assert.match(block, /PC0 版本与运行 \| `PASS`/) assert.match(block, /PC4 规范雷达 \| `UNVERIFIED` \| ENV_MODE unknown/) assert.doesNotMatch(block, /DevCodexVisibleEnvelopeV3/) assert.match(composeEntryCheckBlock({ project: 'demo', envMode: 'dev', languageContext: ZH_LANGUAGE_CONTEXT }), /PC4 规范雷达 \| `UNVERIFIED` \| dev 模式/) diff --git a/scripts/test-mcp-servers.js b/scripts/test-mcp-servers.js index 17498eeb..508c3101 100644 --- a/scripts/test-mcp-servers.js +++ b/scripts/test-mcp-servers.js @@ -7354,7 +7354,9 @@ function testMemorySessionAllocationAndTransactions() { assert.strictEqual(writeSchema.properties.content.maxLength, 262144) assert.strictEqual(writeSchema.properties.sessionId.maxLength, 64) assert.strictEqual(writeSchema.properties.sessionBinding.pattern, '^[a-f0-9]{64}$') - assert.deepStrictEqual(writeSchema.required, ['content']) + assert.strictEqual(writeSchema.required, undefined) + assert.deepStrictEqual(writeSchema.anyOf, [{ required: ['content'] }, { required: ['turn'] }, { required: ['sessionStatus'] }]) + assert.deepStrictEqual(writeSchema.properties.sessionStatus.enum, ['in-progress', 'completed']) const first = JSON.parse(resultById(allocations, 3).content[0].text) const second = JSON.parse(resultById(allocations, 4).content[0].text) assert.deepStrictEqual(resultById(allocations, 3).structuredContent, first) diff --git a/scripts/test-v1178-batch-d.js b/scripts/test-v1178-batch-d.js index 0aeb093c..ce2576f9 100644 --- a/scripts/test-v1178-batch-d.js +++ b/scripts/test-v1178-batch-d.js @@ -538,7 +538,7 @@ function testPublicSchemaRuntimeParity() { const write = tools.find(tool => tool.name === 'memory_session_write') const sessionQuery = tools.find(tool => tool.name === 'memory_session_query') const summaryQuery = tools.find(tool => tool.name === 'memory_summary_query') - assert.deepStrictEqual(write.inputSchema.required, ['content']) + assert.deepStrictEqual(write.inputSchema.anyOf.map(item => item.required[0]), ['content', 'turn', 'sessionStatus']) assert.strictEqual(sessionQuery.inputSchema.properties.cursor.maxLength, 8192) assert.strictEqual(summaryQuery.inputSchema.properties.cursor.maxLength, 8192) diff --git a/scripts/test-visible-output-contract.js b/scripts/test-visible-output-contract.js index 313e5f1c..a7cd6345 100644 --- a/scripts/test-visible-output-contract.js +++ b/scripts/test-visible-output-contract.js @@ -513,8 +513,8 @@ const codexDesktopRendered = renderVisibleEnvelope(codexDesktopEnvelope, { }) assert.match(codexDesktopRendered, /\| 检查项 \| 状态 \| 结论 \|/) assert.match(codexDesktopRendered, /\|---\|---\|---\|/) -assert.match(codexDesktopRendered, /\| PC0 中断处理 \| `PASS` \|/) -assert.match(codexDesktopRendered, /\| PC5 本轮范围 \| `WARN` \|/) +assert.match(codexDesktopRendered, /\| PC0 版本与运行 \| `PASS` \|/) +assert.match(codexDesktopRendered, /\| PC5 宿主 \| `WARN` \|/) assert.strictEqual(analyzeEntryCheckCompleteness(codexDesktopRendered, { mode: 'dev' }).complete, true) const paragraphEntryCheck = [ '### DevCodex · 入口检查',