From 537a2e573a22d4966a3e451f93f8c5ec12ffae73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Sun, 2 Aug 2026 21:15:03 +0800 Subject: [PATCH 1/6] feat(hosts): add Grok CLI host adapter Register Grok as a first-class adapter for configured assets, session evidence, evidence bundles, and portable HTML report routing. Skills installed via symlink under ~/.grok/skills are now inventoried. --- README.md | 2 +- README.zh-CN.md | 2 +- assets/better-harness-architecture-en.svg | 4 +- docs/adapters/README.md | 20 +- docs/docs/hosts/adapter-matrix.md | 16 +- docs/docs/installation.mdx | 2 +- docs/i18n/zh-Hans/code.json | 12 +- .../current/hosts/adapter-matrix.md | 14 +- .../current/installation.mdx | 2 +- docs/specs/2026-08-02-grok-host-adapter.md | 71 +++ docs/src/pages/index.js | 24 +- .../session-evidence/sessions-diagnostics.md | 29 +- scripts/agent-customize/cli.mjs | 6 +- scripts/agent-customize/providers/grok.mjs | 243 ++++++++ scripts/agent-customize/providers/index.mjs | 2 + scripts/agent-lint/cli.mjs | 2 +- .../coding-agent-practices/asset-baseline.mjs | 6 +- .../asset-integrity.mjs | 6 +- scripts/coding-agent-practices/inventory.mjs | 14 +- .../evidence-bundle/agent-customize.mjs | 2 +- .../harness-analysis/evidence-bundle/cli.mjs | 5 +- .../evidence-bundle/contract.mjs | 2 +- scripts/harness-analysis/render-report.mjs | 6 +- scripts/harness-analysis/report-run.mjs | 7 +- scripts/harness-analysis/task-loop-source.mjs | 16 +- scripts/session-analysis/analyzer.mjs | 8 +- scripts/session-analysis/fs.mjs | 14 +- .../lifecycle-demand-signals.mjs | 2 +- scripts/session-analysis/platforms/grok.mjs | 542 ++++++++++++++++++ .../session-analysis/selection-profile.mjs | 2 +- scripts/session-analysis/usage-summary.mjs | 2 +- templates/reporting/routing.md | 2 +- test/agent-customize.test.mjs | 135 +++++ test/better-harness-evidence-bundle.test.mjs | 3 +- test/coding-agent-platform-notes.test.mjs | 3 +- test/docs-entrypoints.test.mjs | 23 +- test/docs-site.test.mjs | 16 +- test/session-analysis-providers.test.mjs | 173 ++++++ test/support-declarations.test.mjs | 6 +- 39 files changed, 1359 insertions(+), 87 deletions(-) create mode 100644 docs/specs/2026-08-02-grok-host-adapter.md create mode 100644 scripts/agent-customize/providers/grok.mjs create mode 100644 scripts/session-analysis/platforms/grok.mjs diff --git a/README.md b/README.md index 158c9b4..f6bfc54 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Choose the host you already use to get its exact installation, verification, invocation, and report-output steps. Better Harness does not use one universal entrypoint across every host. -The canonical registry covers eight host adapters. Pi and WorkBuddy currently +The canonical registry covers nine host adapters. Pi, WorkBuddy, and Grok currently remain adapter-support entries rather than part of the six-host verified Quickstart; see the [public Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md) for their explicit boundaries. diff --git a/README.zh-CN.md b/README.zh-CN.md index 14d83e5..c35a326 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,7 +33,7 @@ 选择你正在使用的宿主,查看对应的安装、验证、调用和报告输出说明。 不同宿主的入口并不完全相同,请直接使用对应章节给出的命令。 -规范注册表当前包含八个宿主适配器。Pi 与 WorkBuddy 仍属于适配器支持入口, +规范注册表当前包含九个宿主适配器。Pi、WorkBuddy 与 Grok 仍属于适配器支持入口, 没有进入包含六个宿主的已验证快速开始;具体边界见 [公开宿主适配矩阵](docs/docs/hosts/adapter-matrix.md)。 diff --git a/assets/better-harness-architecture-en.svg b/assets/better-harness-architecture-en.svg index 2d1bed3..93bbcfe 100644 --- a/assets/better-harness-architecture-en.svg +++ b/assets/better-harness-architecture-en.svg @@ -33,13 +33,13 @@ - 1 · HOST INTEGRATION · 8 CAPABILITY ADAPTERS + 1 · HOST INTEGRATION · 9 CAPABILITY ADAPTERS 6 verified Quickstart hosts Claude · Codex · Qoder · Cursor · Qwen · Copilot - Pi + WorkBuddy adapters + Pi + WorkBuddy + Grok adapters support boundaries documented diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 8f6da02..c754205 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -1,10 +1,10 @@ # Host Adapter Matrix This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, -GitHub Copilot, Pi, and WorkBuddy host boundaries. Do not create `docs/adapters/claude-code.md`, +GitHub Copilot, Pi, WorkBuddy, and Grok host boundaries. Do not create `docs/adapters/claude-code.md`, `docs/adapters/codex.md`, `docs/adapters/qoder.md`, `docs/adapters/cursor.md`, -`docs/adapters/qwen.md`, `docs/adapters/copilot.md`, `docs/adapters/pi.md`, or -`docs/adapters/workbuddy.md` by default. +`docs/adapters/qwen.md`, `docs/adapters/copilot.md`, `docs/adapters/pi.md`, +`docs/adapters/workbuddy.md`, or `docs/adapters/grok.md` by default. Adding another host? Follow [Contributing a New Coding Agent Host](contributing-new-coding-agent.md) before @@ -35,6 +35,7 @@ package through the `pi` manifest in `package.json`. | GitHub Copilot | Analysis-capable source-local host | `.github/plugin/` | `scripts/agent-customize/providers/copilot.mjs` | `scripts/session-analysis/platforms/copilot.mjs` | self-contained HTML + Markdown | `.github` + `AGENTS.md` + `~/.copilot` | `copilot plugin marketplace add .` -> `copilot plugin install better-harness@better-harness` -> configured-asset baseline -> validated `html` render | | Pi | Analysis-capable source-local host | `pi` manifest in `package.json` | `scripts/agent-customize/providers/pi.mjs` | `scripts/session-analysis/platforms/pi.mjs` | self-contained HTML + Markdown | `.pi` + `.agents` + `AGENTS.md` | `pi install ` or `pi -e ` -> `/better-harness` prompt template -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | +| Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | ## Discovery And Evidence @@ -109,6 +110,17 @@ package through the `pi` manifest in `package.json`. override. WorkBuddy has no install shell in this repository; skills install manually into `~/.workbuddy/skills` or through WorkBuddy's own marketplace surfaces. +- Grok configured assets are inventoried through + `scripts/agent-customize/providers/grok.mjs`, covering `~/.grok` user skills + (including bundled skills), hooks, MCP servers declared in `config.toml`, + installed plugins under `installed-plugins/`, shared `.agents/skills`, and + project `.grok` assets. Session evidence comes from + `scripts/session-analysis/platforms/grok.mjs`, which reads workspace-matching + session directories under `~/.grok/sessions///` + (`summary.json`, `updates.jsonl`, optional `chat_history.jsonl` and + `signals.json`). The adapter honors `GROK_HOME`. Grok has no install shell in + this repository; skills install manually into `~/.grok/skills` (symlink is + enough for `/better-harness`). ## Output Modes @@ -118,7 +130,7 @@ Canonical templates live under `templates/reporting/`. `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. - `cursor-canvas.md`: Cursor Canvas output contract, covering the complete report, native Context Usage projection, and public IDE actions. -- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy/Grok visual output contract, covering `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index d4561e7..9d32616 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -13,8 +13,8 @@ host-neutral. ## Support levels -Better Harness currently declares eight capability-level host adapters. Six -have verified public Quickstart paths. Pi and WorkBuddy are visible as adapter +Better Harness currently declares nine capability-level host adapters. Six +have verified public Quickstart paths. Pi, WorkBuddy, and Grok are visible as adapter support because their installation and end-to-end evidence boundaries differ from that six-host set. The [canonical adapter matrix](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md) remains the complete capability-level source of truth. @@ -31,6 +31,7 @@ remains the complete capability-level source of truth. | GitHub Copilot | Verified Quickstart | Analysis-capable source-local host | `.github/plugin/` | Workspace-matched Copilot CLI transcripts; partial coverage stays explicit | Self-contained HTML + Markdown | | Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | +| Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | The `@qoderai/better-harness` npm package includes all six plugin metadata roots. Pi reuses install metadata in the existing `package.json`, so it does @@ -44,7 +45,7 @@ source-local. `canvas.json`, and `report.canvas.tsx`. - **Cursor Canvas** — the same complete report contract rendered with `cursor/canvas`, native Context Window evidence, and IDE actions. -- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy contract +- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy/Grok contract covering `findings.json`, `report.md`, and a self-contained `report.html` (see the [sample report](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. @@ -66,6 +67,15 @@ HTML routing are implemented. This repository does not ship a WorkBuddy install shell, plugin manifest, or npm-packaged host artifact; installation remains on WorkBuddy's own `~/.workbuddy/skills` or marketplace surfaces. +### Grok {#grok} + +Grok configured assets, workspace-matched session evidence, and portable HTML +routing are implemented. This repository does not ship a Grok install shell or +npm-packaged host artifact; installation is a manual skill symlink into +`~/.grok/skills/better-harness` (or project `.grok/skills`). Grok remains +outside the verified Quickstart set until a complete interactive report-loop +smoke is observed. + ## Capability coverage Capabilities differ per host on purpose: no host claims a capability without a diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index b8cee2a..544e7f7 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -41,7 +41,7 @@ Cursor's source-local path also requires Git so you can clone this repository. :::tip Don't see your Coding Agent? The six tabs below are the verified Quickstart paths, while the project tracks -eight host adapters in total. [Compare all adapter support boundaries](./hosts/adapter-matrix), +nine host adapters in total. [Compare all adapter support boundaries](./hosts/adapter-matrix), then [follow the new-host contribution workflow and worked pull requests](./hosts/contributing-new-coding-agent) if you want to add or complete an integration. You can also [browse current repository pull requests](https://github.com/QoderAI/better-harness/pulls) diff --git a/docs/i18n/zh-Hans/code.json b/docs/i18n/zh-Hans/code.json index 63f7707..b44cc55 100644 --- a/docs/i18n/zh-Hans/code.json +++ b/docs/i18n/zh-Hans/code.json @@ -77,6 +77,12 @@ "homepage.hosts.workBuddy.setup": { "message": "证据与报告适配器已可用;安装仍通过 WorkBuddy 自有路径完成。" }, + "homepage.hosts.grok.method": { + "message": "Skill 软链路径" + }, + "homepage.hosts.grok.setup": { + "message": "证据与报告适配器已可用;安装方式是把 Skill 软链到 ~/.grok/skills。" + }, "homepage.hosts.output.html": { "message": "HTML + Markdown 报告" }, @@ -186,16 +192,16 @@ "message": "Agent Work Loop(智能体工作循环)" }, "homepage.how.architectureCaption": { - "message": "八个能力层宿主适配器进入同一条证据流水线。其中六个已有验证过的快速开始路径;Pi 与 WorkBuddy 的当前适配器支持边界会明确展示。" + "message": "九个能力层宿主适配器进入同一条证据流水线。其中六个已有验证过的快速开始路径;Pi、WorkBuddy 与 Grok 的当前适配器支持边界会明确展示。" }, "homepage.how.architectureAlt": { - "message": "Better Harness 架构:六个公开快速开始宿主以及 Pi 与 WorkBuddy 适配器支持进入三个独立证据智能体,再进行统一分析、宿主中立输出和修复" + "message": "Better Harness 架构:六个公开快速开始宿主以及 Pi、WorkBuddy 与 Grok 适配器支持进入三个独立证据智能体,再进行统一分析、宿主中立输出和修复" }, "homepage.quickstart.title": { "message": "选择你的 Coding Agent" }, "homepage.quickstart.intro": { - "message": "目前支持八个宿主适配器。其中六个已有验证过的配置路径;Pi 与 WorkBuddy 会进入各自当前的支持边界说明。" + "message": "目前支持九个宿主适配器。其中六个已有验证过的配置路径;Pi、WorkBuddy 与 Grok 会进入各自当前的支持边界说明。" }, "homepage.meta.title": { "message": "AI 编码智能体工作流洞察" diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index e063bf0..ba641ec 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -12,8 +12,8 @@ Better Harness 运行在你现有的编码智能体内。宿主差异只进入 ## 支持层级 -Better Harness 当前声明了八个能力层宿主适配器,其中六个已有验证过的公开 -快速开始路径。Pi 与 WorkBuddy 以适配器支持展示,因为它们的安装方式和端到端 +Better Harness 当前声明了九个能力层宿主适配器,其中六个已有验证过的公开 +快速开始路径。Pi、WorkBuddy 与 Grok 以适配器支持展示,因为它们的安装方式和端到端 证据边界与这六个宿主不同。完整能力层事实源仍是 [规范适配器矩阵](https://github.com/QoderAI/better-harness/blob/main/docs/adapters/README.md)。 @@ -29,6 +29,7 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 | GitHub Copilot | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.github/plugin/` | 工作区匹配的 Copilot CLI 转录;部分覆盖保持显式标注 | 自包含 HTML + Markdown | | Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话 | 自包含 HTML + Markdown | | WorkBuddy | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 WorkBuddy 自有路径 | 匹配工作区的 WorkBuddy JSONL 转录 | 自包含 HTML + Markdown | +| Grok | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 Grok 自有路径 | 匹配工作区的 Grok 会话目录(`updates.jsonl`) | 自包含 HTML + Markdown | `@qoderai/better-harness` npm 包含全部六个插件元数据根目录。生成的 Qoder 运行时 bundle 只包含 Qoder shell。Pi 复用现有 `package.json` 中的安装元数据, @@ -40,7 +41,7 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 `canvas.json` 和 `report.canvas.tsx`。 - **Cursor Canvas** —— 使用 `cursor/canvas`、原生 Context Window 证据和 IDE actions 渲染同一份完整报告契约。 -- **HTML 可视化** —— 面向 Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy 的可移植契约,覆盖 +- **HTML 可视化** —— 面向 Claude Code/Codex/Qwen/Copilot/Pi/WorkBuddy/Grok 的可移植契约,覆盖 `findings.json`、`report.md` 和自包含的 `report.html` (见[示例报告](pathname:///demo/better-harness-report/))。 - **纯 Markdown** —— 无视觉版本。 @@ -59,6 +60,13 @@ WorkBuddy 的已配置资产、工作区匹配的会话证据与可移植 HTML 本仓库不提供 WorkBuddy 安装 Shell、插件 manifest 或 npm 打包的宿主产物;安装 仍通过 WorkBuddy 自有的 `~/.workbuddy/skills` 或 Marketplace 入口完成。 +### Grok {#grok} + +Grok 的已配置资产、工作区匹配的会话证据与可移植 HTML 路由均已实现。 +本仓库不提供 Grok 安装 Shell 或 npm 打包的宿主产物;安装方式是将 Skill 软链到 +`~/.grok/skills/better-harness`(或项目 `.grok/skills`)。在观察到完整交互式 +报告闭环冒烟验证前,Grok 仍不进入已验证快速开始集合。 + ## 能力覆盖 各宿主的能力刻意保持差异:没有真实证据源的宿主不会声称具备某项能力, diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx index 4c406ea..1265dfa 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx @@ -36,7 +36,7 @@ Cursor 使用源码本地插件,还需要 Git 来克隆本仓库。 :::tip 没有找到你的 Coding Agent? -下方六个标签页是已验证的快速开始路径,项目当前共跟踪八个宿主适配器。 +下方六个标签页是已验证的快速开始路径,项目当前共跟踪九个宿主适配器。 请先[比较全部适配器的支持边界](./hosts/adapter-matrix);如果希望新增或补全 一个集成,再[按照新增宿主贡献流程与 PR 示例操作](./hosts/contributing-new-coding-agent)。 开始重复工作前,也可以先[查看仓库当前的 Pull Requests](https://github.com/QoderAI/better-harness/pulls)。 diff --git a/docs/specs/2026-08-02-grok-host-adapter.md b/docs/specs/2026-08-02-grok-host-adapter.md new file mode 100644 index 0000000..9b8b930 --- /dev/null +++ b/docs/specs/2026-08-02-grok-host-adapter.md @@ -0,0 +1,71 @@ +# Spec: Grok CLI host adapter + +**Date:** 2026-08-02 +**Host id:** `grok` +**Host version (verified):** Grok CLI 0.2.x (user-guide + local `~/.grok`) +**Capability level (this PR):** Partial adapter → Verified assets + sessions + HTML render path +**Non-goals:** Grok marketplace packaging into public npm shell; Canvas mode; reading `auth.json` secrets; claiming full Quickstart until native install smoke is recorded. + +## Support slices + +| Slice | Status | Owner | +| --- | --- | --- | +| Shell / discovery | Partial — Skill path + optional thin docs; no `.grok-plugin` required for analysis | docs + `skills/better-harness` | +| Configured assets | Claimed | `scripts/agent-customize/providers/grok.mjs` | +| Session evidence | Claimed | `scripts/session-analysis/platforms/grok.mjs` | +| Evidence bundle / registries | Claimed | capability indexes + `evidence-bundle` | +| Output | Claimed — HTML visual | `.grok/better-harness` host root | +| Packaging (npm shell) | Unavailable this PR | — | + +## Native contract (verified) + +| Item | Value | +| --- | --- | +| Home | `GROK_HOME` env, else `~/.grok` | +| Config | `$GROK_HOME/config.toml` | +| Skills | `$GROK_HOME/skills/`, `$GROK_HOME/bundled/skills/`, `/.grok/skills/`, `/.agents/skills/` | +| Hooks | `$GROK_HOME/hooks/*.json`, project `.grok/hooks` when present | +| MCP | `[mcp_servers.]` tables in `config.toml` (enabled flag) | +| Plugins | `$GROK_HOME/installed-plugins/`, `marketplace-cache/` (inventory only) | +| Sessions | `$GROK_HOME/sessions///` with `summary.json`, `updates.jsonl`, `chat_history.jsonl`, `signals.json` | +| Report root | `/.grok/better-harness/` | +| Output mode | `html` | + +### Workspace qualification + +- Prefer `summary.json` → `info.cwd` (or equivalent) matched via existing workspace-match helpers. +- Session group directory name is `encodeURIComponent(absoluteCwd)` (e.g. `/Users/work` → `%2FUsers%2Fwork`). +- Foreign-workspace sessions never enter facts for a report. + +### Privacy + +- Never serialize `auth.json`, API keys, or MCP `env` secret values. +- Inventory may record server names, enabled flags, and path existence only. + +## Acceptance ids + +| Id | Criterion | +| --- | --- | +| Grok-A1 | Provider inventory returns skills/hooks/mcp/plugins scopes for synthetic home + workspace | +| Grok-A2 | `GROK_HOME` / `--grok-home` overrides default without foreign home fallback | +| Grok-S1 | Session sources list only cwd-matching sessions under encoded group dir | +| Grok-S2 | Foreign session group excluded from sources | +| Grok-S3 | Missing `signals.json` usage stays unobserved (not zero-filled) | +| Grok-S4 | Unknown `updates.jsonl` events preserved as metadata | +| Grok-R1 | `platform=grok` accepted by evidence-bundle and session-analysis CLI help | +| Grok-R2 | HTML render default out root documents `.grok/better-harness` | + +## Smoke (local) + +```bash +node scripts/session-analysis/analyzer.mjs sources --platform grok --workspace +node scripts/agent-customize/cli.mjs inventory --provider grok --workspace +node scripts/better-harness.mjs harness evidence-bundle --platform grok --workspace --depth quick --format json +``` + +Install skill for Grok TUI: + +```bash +ln -sfn /skills/better-harness ~/.grok/skills/better-harness +# then in a target repo: /better-harness … +``` diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js index 5da2ad6..51b3e46 100644 --- a/docs/src/pages/index.js +++ b/docs/src/pages/index.js @@ -272,6 +272,24 @@ function hosts() { action: supportAction, to: "/docs/hosts/adapter-matrix#workbuddy", }, + { + name: "Grok", + method: translate({ + id: "homepage.hosts.grok.method", + message: "Skill symlink path", + }), + output: htmlOutput, + setup: translate({ + id: "homepage.hosts.grok.setup", + message: + "Evidence and report adapters are available; install by symlinking the skill into ~/.grok/skills.", + }), + anchor: "grok", + supportLevel: "adapter", + status: adapterStatus, + action: supportAction, + to: "/docs/hosts/adapter-matrix#grok", + }, ]; } @@ -481,7 +499,7 @@ function HowItWorks() { alt={translate({ id: "homepage.how.architectureAlt", message: - "Better Harness architecture: six public Quickstart hosts plus Pi and WorkBuddy adapter support feed three independent evidence agents, unified analysis, host-neutral outputs, and repair", + "Better Harness architecture: six public Quickstart hosts plus Pi, WorkBuddy, and Grok adapter support feed three independent evidence agents, unified analysis, host-neutral outputs, and repair", })} width="1800" height="1360" @@ -492,7 +510,7 @@ function HowItWorks() {

Eight capability-level host adapters feed the same evidence - pipeline. Six have verified Quickstart paths; Pi and WorkBuddy keep + pipeline. Six have verified Quickstart paths; Pi, WorkBuddy, and Grok keep their current adapter-support boundaries explicit.

@@ -516,7 +534,7 @@ function QuickStart() {

Eight host adapters are supported. Six have verified setup paths; - Pi and WorkBuddy link to their current support boundaries. + Pi, WorkBuddy, and Grok link to their current support boundaries.

diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index aadc359..0226d69 100644 --- a/references/session-evidence/sessions-diagnostics.md +++ b/references/session-evidence/sessions-diagnostics.md @@ -12,22 +12,22 @@ fails validation: ```bash # Discover evidence roots for a workspace - scripts/session-analysis.mjs sources --platform --workspace /path/to/repo + scripts/session-analysis.mjs sources --platform --workspace /path/to/repo # Session list with event counts and time range - scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 # Compact insight cards and action candidates - scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 # Read single session events - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events # Diagnose the facts admission funnel and resolve candidate refs to local sessions - scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json + scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json # Expand one debug locator with normalized commands and user text - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text ``` `facts --debug` is an operator-only diagnostic route. It exposes raw session @@ -38,7 +38,7 @@ opening only the candidate sessions needed to explain a surprising aggregate. Command and user text flags are also local-only and must not be used for broad transcript dumps. -Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, and `workbuddy`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, `workbuddy`, and `grok`. Do not invent unsupported platform names. Always pass the absolute target workspace and load the matching Platform Notes before interpreting source roots or workspace bindings. @@ -330,3 +330,18 @@ Route configured WorkBuddy rules (`AGENTS.md`, identity files), Skills, marketplace plugins, MCP config, and other project/user assets through `../agent-customize/global-assets.md`; configured presence does not prove use. + +### Grok + +For Grok, the analyzer reads workspace-matching session directories under +`~/.grok/sessions///`. The group directory name is +`encodeURIComponent(absoluteCwd)`, so `/Users/work` maps to +`~/.grok/sessions/%2FUsers%2Fwork/`. Prefer `summary.json` `info.cwd` for +qualification; fall back to decoding the group directory name. Primary +transcript is `updates.jsonl` (ACP-style `session/update` records). Optional +`chat_history.jsonl` is secondary, and `signals.json` may contribute sparse +usage when present. Missing signals leave usage unobserved rather than +zero-filled. The `GROK_HOME` environment variable relocates the data root. +Route configured Grok skills, hooks, MCP servers in `config.toml`, installed +plugins, and project `.grok` assets through `../agent-customize/global-assets.md`; +configured presence does not prove use. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index 944b916..3dded47 100644 --- a/scripts/agent-customize/cli.mjs +++ b/scripts/agent-customize/cli.mjs @@ -5,12 +5,12 @@ import { collectAgentCustomizeInventory, filterManageItems, groupManageItems } f function usage() { return [ - "Usage: better-harness agent-customize [inventory|manage] --provider [--workspace ]", + "Usage: better-harness agent-customize [inventory|manage] --provider [--workspace ]", " better-harness agent-customize manage --provider [--tab ] [--query ] [--scope ] [--group-by ]", "", "Collect configured agent-customize inventory for one provider as JSON.", "Provider home overrides: --cursor-home, --qoder-home, --codex-home, --claude-home,", - "--qwen-home, --copilot-home, --pi-home, --workbuddy-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --copilot-home, --pi-home, --workbuddy-home, --grok-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } @@ -33,6 +33,7 @@ function summarize(inventory, options) { copilotHome: inventory.copilotHome, piHome: inventory.piHome, workbuddyHome: inventory.workbuddyHome, + grokHome: inventory.grokHome, claudeStatePath: inventory.claudeStatePath, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, @@ -78,6 +79,7 @@ async function main() { copilotHome: options["copilot-home"], piHome: options["pi-home"], workbuddyHome: options["workbuddy-home"], + grokHome: options["grok-home"], claudeStatePath: options["claude-state"] ?? options["claude-state-path"], codexAppPath: options["codex-app-path"], qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"], diff --git a/scripts/agent-customize/providers/grok.mjs b/scripts/agent-customize/providers/grok.mjs new file mode 100644 index 0000000..8ef8610 --- /dev/null +++ b/scripts/agent-customize/providers/grok.mjs @@ -0,0 +1,243 @@ +/** + * Grok CLI configured-asset inventory. + * Native home: GROK_HOME or ~/.grok. Project: /.grok and .agents. + */ +import os from "node:os"; +import path from "node:path"; +import { readdir } from "node:fs/promises"; + +import { expandHome, normalizeWorkspace, pathExists } from "../../session-analysis/index.mjs"; +import { MANAGE_TABS } from "../constants.mjs"; +import { + agentsMarkdownRuleSource, + buildManageCollections, + collectHookItems, + collectMarkdownItems, + collectMcpItems, + collectRuleSources, + collectSkillFiles, + collectWorkspaceRootPrimitives, + designMarkdownRuleSource, + evidence, + listDirectories, + normalizePluginDisplayName, + readJson, + readText, + sortByName, + titleCase, + workspaceSourceLabel, +} from "../core/items.mjs"; + +export function defaultGrokHome() { + return process.env.GROK_HOME ?? path.join(os.homedir(), ".grok"); +} + +function emptyPrimitives() { + return { skills: [], subagents: [], rules: [], commands: [], hooks: [], mcps: [] }; +} + +/** + * Minimal TOML table scanner for [mcp_servers.name] / [mcp_servers."name"] blocks. + * Does not evaluate full TOML; only extracts server identity and enabled flags. + */ +export function parseGrokMcpServersFromToml(text) { + if (typeof text !== "string" || text.trim() === "") return []; + const servers = []; + const lines = text.split(/\r?\n/u); + let current = null; + for (const rawLine of lines) { + const line = rawLine.replace(/#.*$/u, "").trim(); + if (!line) continue; + const header = line.match(/^\[mcp_servers\.("?)([^"\]]+)\1\]$/u); + if (header) { + if (current) servers.push(current); + current = { + name: header[2], + enabled: true, + hasCommand: false, + hasUrl: false, + }; + continue; + } + if (!current) continue; + if (/^\[/u.test(line)) { + servers.push(current); + current = null; + continue; + } + const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/u); + if (!kv) continue; + const key = kv[1]; + const value = kv[2].trim(); + if (key === "enabled") { + current.enabled = !/^(false|0|no|off)$/iu.test(value); + } else if (key === "command") { + current.hasCommand = true; + } else if (key === "url") { + current.hasUrl = true; + } + } + if (current) servers.push(current); + return servers; +} + +async function readTomlText(filePath) { + try { + return await readText(filePath, 256_000); + } catch { + return null; + } +} + +async function collectGrokMcpFromConfig(configPath, scope, sourceLabel, rootForEvidence) { + const text = await readTomlText(configPath); + if (!text) return []; + const servers = parseGrokMcpServersFromToml(text); + return servers.map((server) => ({ + id: `grok/${scope}/mcp/${server.name}`, + name: server.name, + scope, + sourceLabel, + enabled: server.enabled !== false, + transport: server.hasUrl ? "http" : (server.hasCommand ? "stdio" : "unknown"), + evidence: evidence(configPath, rootForEvidence), + // Secrets in env= never serialized. + unsupported: ["mcp env secret values"], + })).sort(sortByName); +} + +async function collectInstalledPlugins(pluginsRoot) { + if (!(await pathExists(pluginsRoot))) return []; + const plugins = []; + for (const pluginRoot of await listDirectories(pluginsRoot)) { + const name = path.basename(pluginRoot); + const manifestCandidates = [ + path.join(pluginRoot, "plugin.json"), + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + path.join(pluginRoot, ".grok-plugin", "plugin.json"), + ]; + let manifest = null; + let manifestPath = null; + for (const candidate of manifestCandidates) { + manifest = await readJson(candidate); + if (manifest) { + manifestPath = candidate; + break; + } + } + const displayName = normalizePluginDisplayName( + manifest?.displayName || manifest?.name || titleCase(name), + name, + ); + const plugin = { + id: `grok/installed/${name}`, + rootPath: pluginRoot, + scope: "plugin", + sourceLabel: displayName, + name: manifest?.name || name, + displayName, + description: manifest?.description || "", + installSources: ["installed-plugins"], + installSource: "installed-plugins", + installMatch: "grok-installed-plugins-dir", + installType: "local", + enabled: true, + evidence: evidence(manifestPath ?? pluginRoot, path.dirname(pluginRoot)), + }; + plugin.skills = (await collectSkillFiles(pluginRoot, "plugin", displayName, pluginRoot)).sort(sortByName); + plugin.commands = await collectMarkdownItems( + path.join(pluginRoot, "commands"), + "command", + "plugin", + displayName, + pluginRoot, + ); + plugin.rules = []; + plugin.subagents = []; + plugin.hooks = await collectHookItems(pluginRoot, "plugin", displayName, pluginRoot); + plugin.mcpServers = await collectMcpItems(pluginRoot, "plugin", displayName, pluginRoot); + plugins.push(plugin); + } + return plugins.sort(sortByName); +} + +async function collectGrokUserPrimitives(grokHome) { + const configPath = path.join(grokHome, "config.toml"); + const [skills, bundledSkills, commands, hooks, mcps] = await Promise.all([ + collectSkillFiles(path.join(grokHome, "skills"), "user", "User", grokHome), + collectSkillFiles(path.join(grokHome, "bundled", "skills"), "user", "User bundled", grokHome), + collectMarkdownItems(path.join(grokHome, "commands"), "command", "user", "User", grokHome), + collectHookItems(grokHome, "user", "User", grokHome), + collectGrokMcpFromConfig(configPath, "user", "User", grokHome), + ]); + return { + skills: [...skills, ...bundledSkills].sort(sortByName), + subagents: [], + rules: [], + commands, + hooks, + mcps, + }; +} + +async function collectGrokWorkspacePrimitives(workspace) { + const sourceLabel = await workspaceSourceLabel(workspace); + const projectRoot = path.join(workspace, ".grok"); + const project = await collectWorkspaceRootPrimitives(projectRoot, sourceLabel, workspace); + const agentsSkills = await collectSkillFiles( + path.join(workspace, ".agents", "skills"), + "project", + sourceLabel, + workspace, + ); + const projectMcps = await collectMcpItems(workspace, "project", sourceLabel, workspace); + return { + ...project, + skills: [...project.skills, ...agentsSkills].sort(sortByName), + mcps: [...project.mcps, ...projectMcps].sort(sortByName), + rules: [ + ...project.rules, + ...(await collectRuleSources([ + agentsMarkdownRuleSource(workspace, sourceLabel), + designMarkdownRuleSource(workspace, sourceLabel), + ])), + ], + }; +} + +export async function collectGrokCustomizeInventory(options = {}) { + const grokHome = path.resolve(expandHome( + options.grokHome ?? options["grok-home"] ?? defaultGrokHome(), + )); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const includeUserHome = options.includeUserHome !== false; + const [plugins, user, project] = await Promise.all([ + includeUserHome ? collectInstalledPlugins(path.join(grokHome, "installed-plugins")) : [], + includeUserHome ? collectGrokUserPrimitives(grokHome) : emptyPrimitives(), + collectGrokWorkspacePrimitives(workspace), + ]); + return { + generatedAt: new Date().toISOString(), + provider: "grok", + grokHome, + workspace, + tabs: MANAGE_TABS, + plugins, + manage: buildManageCollections(plugins, user, project), + diagnostics: { + installedPluginState: plugins.length > 0 ? "grok-installed-plugins-dir" : "missing", + installedPluginRecordCount: plugins.length, + installedPluginRecordFiles: includeUserHome && (await pathExists(path.join(grokHome, "installed-plugins"))) + ? [path.join(grokHome, "installed-plugins")] + : [], + remotePluginInstallMarkersRequired: false, + configPath: path.join(grokHome, "config.toml"), + }, + unsupported: [ + "auth.json credentials (never inventoried as values)", + "MCP env secret values from config.toml", + "marketplace-cache catalog entries without install", + "runtime plugin trust state beyond enabled inventory", + ], + }; +} diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index 3275c13..8e7f5c9 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -2,6 +2,7 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; +import { collectGrokCustomizeInventory } from "./grok.mjs"; import { collectPiCustomizeInventory } from "./pi.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; import { collectQwenCustomizeInventory } from "./qwen.mjs"; @@ -16,6 +17,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["copilot", collectCopilotCustomizeInventory], ["pi", collectPiCustomizeInventory], ["workbuddy", collectWorkbuddyCustomizeInventory], + ["grok", collectGrokCustomizeInventory], ]); export async function collectProviderInventory(provider, options = {}) { diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index c76c3a7..0d29bf2 100644 --- a/scripts/agent-lint/cli.mjs +++ b/scripts/agent-lint/cli.mjs @@ -167,7 +167,7 @@ function usage() { return [ "Usage: better-harness agent-lint [--workspace ] [--profile agents-md-review|agent-assets-review] [--json|--format markdown]", " better-harness agent-lint --workspace-root --scan-children --profile agents-md-review", - " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", + " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", "", "Parse agent instruction entrypoints and bounded local Markdown references into review evidence.", "", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index a15277d..62c846b 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -14,7 +14,7 @@ export const ASSET_BASELINE_SCHEMA_VERSION = 1; export const MAX_BASELINE_FINDINGS = 16; export const MAX_BASELINE_OWNER_ROUTES = 16; -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]); const SEVERITY_RANK = Object.freeze({ error: 0, warning: 1, advisory: 2 }); const OWNER_KIND_RANK = Object.freeze({ rules: 0, @@ -278,7 +278,7 @@ function mergeInheritedInventories(projectInventory, inheritedInventories, topol export async function collectAssetBaseline(options = {}, dependencies = {}) { const provider = options.provider ?? options.platform ?? "qoder"; if (!PROVIDERS.has(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy, grok.`); } const workspace = normalizeWorkspace(options.workspace ?? "."); const includeUserHome = parseBooleanFlag(options.includeUserHome ?? options["include-user-home"] ?? false); @@ -403,7 +403,7 @@ export function formatAssetBaselineMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy|grok] [options] Collect one compact, read-only AI evidence envelope from a shared asset snapshot. diff --git a/scripts/coding-agent-practices/asset-integrity.mjs b/scripts/coding-agent-practices/asset-integrity.mjs index 33f1688..51e09b7 100644 --- a/scripts/coding-agent-practices/asset-integrity.mjs +++ b/scripts/coding-agent-practices/asset-integrity.mjs @@ -340,7 +340,7 @@ export function formatAssetIntegrityMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy|grok] [options] Run a read-only metadata integrity review for Memory titles, enabled Plugins, and Hooks. Memory bodies are never read. @@ -368,8 +368,8 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const provider = options.provider ?? options.platform ?? command ?? "qoder"; - if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy.`); + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"].includes(provider)) { + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy, grok.`); } const includeUserHome = options.includeUserHome ?? options["include-user-home"] ?? false; const includeMemories = options.includeMemories ?? options["include-memories"] ?? false; diff --git a/scripts/coding-agent-practices/inventory.mjs b/scripts/coding-agent-practices/inventory.mjs index 4e7eec4..7d71dbd 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -384,7 +384,7 @@ async function collectCodexMemories(scope) { } function makeSessionSourceHints(scope) { - if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"].includes(scope.platform)) { return []; } return [ @@ -456,6 +456,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], + grokHome: options.grokHome ?? options["grok-home"], }; } @@ -550,7 +551,7 @@ function customizeSurface({ provider, group, scope, type, label, basePath, items async function buildConfiguredAssetSurfaces(inventory, scope) { const provider = scope.platform; const projectBase = scope.workspace; - const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome ?? inventory.piHome ?? inventory.workbuddyHome; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome ?? inventory.piHome ?? inventory.workbuddyHome ?? inventory.grokHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -673,6 +674,7 @@ export async function collectProviderInventory(options = {}) { copilotHome: scope.copilotHome, piHome: scope.piHome, workbuddyHome: scope.workbuddyHome, + grokHome: scope.grokHome, includeUserHome: scope.includeUserHome, includeGlobalHooks: scope.includeGlobalHooks, }); @@ -926,12 +928,12 @@ export function formatInventoryMarkdown(result) { return `${lines.join("\n")}\n`; } -const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot|pi|workbuddy|grok] [options] Inspect configured coding-agent assets and practice evidence for one platform. Options: - --platform Select the platform (default: qoder; may also be the first positional) + --platform Select the platform (default: qoder; may also be the first positional) --workspace Workspace root to inspect (default: current directory) --json Emit JSON (default) --format Output format @@ -953,9 +955,9 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const platform = options.platform ?? command ?? "qoder"; - if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi", "workbuddy"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi", "workbuddy", "grok"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi, workbuddy.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi, workbuddy, grok.\n\n${USAGE}`, ); } const result = platform === "qoder" diff --git a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs index 50a9d12..2d73609 100644 --- a/scripts/harness-analysis/evidence-bundle/agent-customize.mjs +++ b/scripts/harness-analysis/evidence-bundle/agent-customize.mjs @@ -1,7 +1,7 @@ import { collectAssetBaseline } from "../../coding-agent-practices/asset-baseline.mjs"; import { availableLane, unavailableLane } from "./contract.mjs"; -const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]); export async function collectAgentCustomize(context, options = {}, dependencies = {}) { if (!ASSET_PROVIDERS.has(context.provider)) { diff --git a/scripts/harness-analysis/evidence-bundle/cli.mjs b/scripts/harness-analysis/evidence-bundle/cli.mjs index 0184243..4052fb9 100644 --- a/scripts/harness-analysis/evidence-bundle/cli.mjs +++ b/scripts/harness-analysis/evidence-bundle/cli.mjs @@ -13,7 +13,7 @@ Harness, and Agent Customize specialists plus the lead analyzer. Options: --workspace Target workspace (required) - --platform qoder, codex, claude, cursor, qwen, copilot, pi, or workbuddy (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy, or grok (default: qoder) --language Evidence language (default: en) --depth 7-day/3-item or 30-day/5-item review (default: normal) --since Override the frozen window start @@ -24,6 +24,7 @@ Options: --claude-home Claude config root override --claude-state Claude state-file override --workbuddy-home WorkBuddy data root override + --grok-home Grok CLI data root override --canvas-out With Qoder or Cursor, initialize canvas.json from lead facts --replace-canvas Replace that canvas.json when explicitly authorized --format json JSON only @@ -34,7 +35,7 @@ const ALLOWED = new Set([ "workspace", "platform", "provider", "language", "depth", "since", "until", "evidence-limit", "include-user-home", "include-memories", "canvas-out", "replace-canvas", "format", "json", "qoder-home", "codex-home", "claude-home", - "cursor-home", "qwen-home", "copilot-home", "pi-home", "workbuddy-home", "claude-state", "help", "h", + "cursor-home", "qwen-home", "copilot-home", "pi-home", "workbuddy-home", "grok-home", "claude-state", "help", "h", ]); function assertOptions(command, options) { diff --git a/scripts/harness-analysis/evidence-bundle/contract.mjs b/scripts/harness-analysis/evidence-bundle/contract.mjs index 7049151..61abb71 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -14,7 +14,7 @@ export const EVIDENCE_LANE_NAMES = Object.freeze([ "agentCustomize", ]); -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]); const DEPTHS = new Set(["quick", "normal"]); function enabled(value) { diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 0d13498..4133468 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -87,7 +87,11 @@ function parseArgs(argv) { throw Object.assign(new Error(`Unknown argument: ${arg}`), { code: "UNKNOWN_ARGUMENT" }); } } - options.out ??= options.mode === "cursor-canvas" ? ".cursor/better-harness" : ".qoder/better-harness"; + options.out ??= options.mode === "cursor-canvas" + ? ".cursor/better-harness" + : options.mode === "html" && String(options.platform ?? options.provider ?? "").toLowerCase() === "grok" + ? ".grok/better-harness" + : ".qoder/better-harness"; return options; } diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index c66eca4..47431c8 100644 --- a/scripts/harness-analysis/report-run.mjs +++ b/scripts/harness-analysis/report-run.mjs @@ -19,7 +19,7 @@ an explicit Qoder Canvas output is requested. Options: --workspace Target workspace (required) - --platform qoder, codex, claude, cursor, qwen, copilot, pi, or workbuddy (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy, or grok (default: qoder) --language en or zh-CN (default: en) --since Include sessions at or after the frozen window start --until Include sessions at or before the frozen window end @@ -34,7 +34,7 @@ function clone(value) { return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); } -const REPORT_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"]; +const REPORT_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]; function reportPlatform(value = "qoder") { const platform = String(value || "qoder").toLowerCase(); @@ -58,7 +58,7 @@ function flagEnabled(value) { function assertCliOptions(options) { const allowed = new Set([ "workspace", "platform", "language", "since", "until", "format", "canvas-out", "replace-canvas", "include-global-capabilities", - "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", "pi-home", "workbuddy-home", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", "pi-home", "workbuddy-home", "grok-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); @@ -172,6 +172,7 @@ export async function analyzeHarnessEvidence(options = {}) { piHome: options["pi-home"], sessionPopulation: options.sessionPopulation, workbuddyHome: options["workbuddy-home"], + grokHome: options["grok-home"], topology: options.topology, analysisScope: options.analysisScope, }); diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index 4f20f3a..c5011dc 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -82,7 +82,7 @@ validation relevance, repair, delivery, recovery, and Learning Capture remain unobserved until the prepared source-bound review resolves them. Options: - --platform + --platform Session platform (default: qoder) --workspace Target workspace (required) --source Candidate report.source.json path (required) @@ -845,7 +845,7 @@ export function buildTaskLoopSourceCandidate({ export async function collectAgentLintPracticeEvidence(options = {}) { const provider = options.platform ?? "qoder"; - const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"].includes(provider); const common = { workspace: options.workspace, provider, @@ -857,6 +857,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], + grokHome: options.grokHome ?? options["grok-home"], topology: options.topology, analysisScope: options.analysisScope, }; @@ -977,6 +978,16 @@ export function collectTaskLoopPracticeInventory(options = {}, platform = option workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], }); } + if (platform === "grok") { + return collectProviderInventory({ + platform, + workspace: options.workspace, + includeUserHome: includeGlobalCapabilities, + includeGlobalHooks: true, + includeMemories: false, + grokHome: options.grokHome ?? options["grok-home"], + }); + } return Promise.resolve(null); } @@ -1093,6 +1104,7 @@ export async function createTaskLoopSourceFromSessions(options = {}) { copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], + grokHome: options.grokHome ?? options["grok-home"], includeGlobalCapabilities: options.includeGlobalCapabilities ?? options["include-global-capabilities"] ?? false, diff --git a/scripts/session-analysis/analyzer.mjs b/scripts/session-analysis/analyzer.mjs index 945d7e0..17368fb 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -19,7 +19,7 @@ import { createCodexCliJsonModelClient } from "./codex-json-model.mjs"; export const SESSION_ANALYSIS_HELP = `Usage: better-harness session-analysis [command] [options] -Inspect local Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or WorkBuddy session evidence. The +Inspect local Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, WorkBuddy, or Grok session evidence. The default command is sessions and the default platform is qoder. Help exits before reading HOME or workspace. @@ -35,7 +35,7 @@ Commands: events Show normalized events selected with --session-id Options: - --platform + --platform Session host (default: qoder) --workspace Workspace scope (default: current directory) --qoder-home Qoder data root (default: ~/.qoder) @@ -46,6 +46,7 @@ Options: --copilot-home Copilot CLI data root (default: ~/.copilot) --pi-home Pi agent data root (default: ~/.pi/agent) --workbuddy-home WorkBuddy data root (default: ~/.workbuddy) + --grok-home Grok CLI data root (default: ~/.grok or $GROK_HOME) --include-cache Include optional Qoder cache evidence --include-global-capabilities Include optional user-global Qoder evidence @@ -239,6 +240,7 @@ const PLATFORM_MODULES = Object.freeze({ copilot: { specifier: "./platforms/copilot.mjs", analyzer: "CopilotSessionAnalyzer" }, pi: { specifier: "./platforms/pi.mjs", analyzer: "PiSessionAnalyzer" }, workbuddy: { specifier: "./platforms/workbuddy.mjs", analyzer: "WorkbuddySessionAnalyzer" }, + grok: { specifier: "./platforms/grok.mjs", analyzer: "GrokSessionAnalyzer" }, }); export const SUPPORTED_SESSION_PLATFORMS = Object.freeze(Object.keys(PLATFORM_MODULES)); @@ -307,7 +309,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { ] : []; stdout.write([ - `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, + `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, "", "Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets", ...factsOptions, diff --git a/scripts/session-analysis/fs.mjs b/scripts/session-analysis/fs.mjs index 1d23b70..b70f944 100644 --- a/scripts/session-analysis/fs.mjs +++ b/scripts/session-analysis/fs.mjs @@ -79,10 +79,18 @@ export async function walkFiles(root, options = {}) { return; } const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { + // Dirent isDirectory/isFile are false for symlinks; follow them so hosts + // that install skills via ln -s (e.g. Grok ~/.grok/skills) are inventoried. + const linkedDirectory = entry.isSymbolicLink() ? await isDirectory(fullPath) : false; + if (entry.isDirectory() || linkedDirectory) { await visit(fullPath, depth + 1); - } else if (entry.isFile() && (!options.match || options.match(fullPath))) { - files.push(fullPath); + } else if ( + (entry.isFile() || entry.isSymbolicLink()) + && (!options.match || options.match(fullPath)) + ) { + if (entry.isFile() || (await pathStat(fullPath))?.isFile()) { + files.push(fullPath); + } } } } diff --git a/scripts/session-analysis/lifecycle-demand-signals.mjs b/scripts/session-analysis/lifecycle-demand-signals.mjs index 027c51e..8c4919a 100644 --- a/scripts/session-analysis/lifecycle-demand-signals.mjs +++ b/scripts/session-analysis/lifecycle-demand-signals.mjs @@ -540,7 +540,7 @@ function fingerprint(value) { function safeHost(value) { const host = String(value ?? "").toLowerCase(); - return ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"].includes(host) ? host : "unknown"; } function safeEvidenceToken(value, fallback) { diff --git a/scripts/session-analysis/platforms/grok.mjs b/scripts/session-analysis/platforms/grok.mjs new file mode 100644 index 0000000..87e14ed --- /dev/null +++ b/scripts/session-analysis/platforms/grok.mjs @@ -0,0 +1,542 @@ +#!/usr/bin/env node +/** + * Grok CLI session evidence adapter. + * Sessions live at $GROK_HOME/sessions/// + * with summary.json + updates.jsonl + optional signals.json. + */ +import { realpathSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SessionAnalyzer } from "../analyzer.mjs"; +import { parseArgs, parseBooleanFlag } from "../cli.mjs"; +import { forEachJsonLine, isDirectory, pathExists, walkFiles } from "../fs.mjs"; +import { expandHome, normalizeWorkspace } from "../paths.mjs"; +import { + bindSessionWorkspaceCwds, + emitProviderResult, + markSessionReadCoverage, + runProviderAnalysis, + runProviderCommand, + sessionWorkspaceCwd, + workspaceMatchScopeFromOptions, +} from "../provider-runner.mjs"; +import { parseResultFacts } from "../result-facts.mjs"; +import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; +import { WORKSPACE_CWD_MATCH, classifyWorkspaceCwd } from "../workspace-match.mjs"; + +function isWorkspaceMatch(candidate, workspace) { + if (!candidate) return false; + const resolved = normalizeWorkspace(candidate); + return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); +} + +function isScopedWorkspaceMatch(candidate, scope) { + if (!scope?._workspaceMatchScope) return isWorkspaceMatch(candidate, scope.workspace); + return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; +} + +/** Grok groups sessions under encodeURIComponent(absoluteCwd). */ +export function workspaceToGrokSessionDirName(workspace) { + // Always use path.resolve semantics so Unix absolute paths stay "/...". + // path.win32.isAbsolute treats leading "/" as absolute and would flip separators. + return encodeURIComponent(normalizeWorkspace(workspace ?? process.cwd())); +} + +export function defaultGrokHome() { + return process.env.GROK_HOME ?? path.join(expandHome("~"), ".grok"); +} + +function evidenceRef(sourceRef, type, itemIndex = null) { + return { + kind: sourceRef.kind, + path: sourceRef.path, + line: sourceRef.line ?? null, + seq: itemIndex, + type, + }; +} + +function finiteNumber(...values) { + return values.find((value) => typeof value === "number" && Number.isFinite(value)); +} + +function normalizeUsageFromSignals(signals) { + if (!signals || typeof signals !== "object") return null; + const observed = {}; + for (const [key, value] of [ + ["inputTokens", finiteNumber(signals.inputTokens, signals.input_tokens, signals.promptTokens)], + ["outputTokens", finiteNumber(signals.outputTokens, signals.output_tokens, signals.completionTokens)], + ["totalTokens", finiteNumber(signals.totalTokens, signals.total_tokens, signals.contextTokensUsed)], + ]) { + if (value !== undefined) observed[key] = value; + } + return Object.keys(observed).length > 0 ? observed : null; +} + +function inferTimestamp(raw) { + const ts = raw?.timestamp ?? raw?.params?.update?._meta?.agentTimestampMs ?? null; + if (typeof ts === "number" && ts > 1e12) { + // Grok sometimes stores agent timestamps in a non-unix epoch scale; prefer ISO when present. + return normalizeTimestamp(raw?.params?.update?._meta?.isoTimestamp ?? raw?.time ?? null) + ?? normalizeTimestamp(ts); + } + return normalizeTimestamp(ts); +} + +/** + * Normalize one ACP-style updates.jsonl record into session events. + */ +function updatesRecordToEvents(raw, sourceRef, options = {}) { + const base = { + sessionId: sourceRef.sessionId, + timestamp: inferTimestamp(raw) ?? normalizeTimestamp(null), + sourceKind: sourceRef.kind, + planningScope: "workspace", + cwd: sourceRef.cwd ?? null, + isSubagent: null, + }; + const method = raw?.method ?? null; + const update = raw?.params?.update ?? raw?.update ?? null; + const sessionUpdate = update?.sessionUpdate ?? update?.type ?? null; + const events = []; + + if (method === "session/update" && sessionUpdate) { + if (sessionUpdate === "user_message_chunk" || sessionUpdate === "user_message") { + const text = update?.content?.text ?? update?.text ?? ""; + const content = typeof text === "string" ? text : ""; + events.push({ + ...base, + type: "user", + category: "user", + evidenceRef: evidenceRef(sourceRef, "user"), + summary: content ? `user message (${content.length} chars)` : "user", + contentLength: content.length, + userPrompt: content.length > 0, + ...(options.includeUserText && content ? { userText: content } : {}), + ...(options.includeContent && content ? { content } : {}), + }); + return events; + } + if (sessionUpdate === "agent_message_chunk" || sessionUpdate === "agent_message" || sessionUpdate === "assistant_message") { + const text = update?.content?.text ?? update?.text ?? ""; + const content = typeof text === "string" ? text : ""; + events.push({ + ...base, + type: "assistant", + category: "assistant", + evidenceRef: evidenceRef(sourceRef, "assistant"), + summary: content ? `assistant message (${content.length} chars)` : "assistant", + contentLength: content.length, + ...(content ? { userVisibleAssistantMessage: true } : {}), + ...(options.includeContent && content ? { content } : {}), + model: update?._meta?.modelId ?? null, + }); + return events; + } + if (sessionUpdate === "tool_call" || sessionUpdate === "tool_call_update") { + const toolName = update?.toolName ?? update?.name ?? update?.title ?? "unknown-tool"; + const status = String(update?.status ?? update?.state ?? "").toLowerCase(); + const isResult = sessionUpdate === "tool_call_update" + || ["completed", "failed", "error", "cancelled", "canceled"].includes(status); + if (isResult) { + const success = !["failed", "error", "cancelled", "canceled"].includes(status); + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolName, + toolInvocationId: update?.toolCallId ?? update?.id ?? null, + success, + hasError: !success, + evidenceRef: evidenceRef(sourceRef, "tool.result"), + summary: success ? "tool result" : `tool result ${status || "failed"}`, + }; + const rawResult = update?.content ?? update?.result ?? update?.output ?? null; + if (rawResult != null) { + const facts = parseResultFacts(String(typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult)).slice(-8_192)); + if (facts) event.resultFacts = facts; + } + events.push(event); + } else { + events.push({ + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName, + toolInvocationId: update?.toolCallId ?? update?.id ?? null, + evidenceRef: evidenceRef(sourceRef, "tool.call"), + summary: `${toolName} request`, + }); + } + return events; + } + events.push({ + ...base, + type: `metadata.${sessionUpdate}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${sessionUpdate}`), + summary: sessionUpdate, + }); + return events; + } + + // Unknown or non-session records stay explicit metadata (do not drop). + events.push({ + ...base, + type: `metadata.${method || raw?.type || "record"}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${method || raw?.type || "record"}`), + summary: method || raw?.type || "record", + }); + return events; +} + +function chatHistoryToEvents(raw, sourceRef, options = {}) { + const role = raw?.role ?? raw?.type ?? null; + const content = typeof raw?.content === "string" + ? raw.content + : (Array.isArray(raw?.content) + ? raw.content.map((part) => part?.text ?? "").join("") + : ""); + const base = { + sessionId: sourceRef.sessionId, + timestamp: normalizeTimestamp(raw?.timestamp ?? null), + sourceKind: sourceRef.kind, + planningScope: "workspace", + cwd: sourceRef.cwd ?? null, + isSubagent: null, + }; + if (role === "user" || role === "human") { + return [{ + ...base, + type: "user", + category: "user", + evidenceRef: evidenceRef(sourceRef, "user"), + summary: content ? `user message (${content.length} chars)` : "user", + contentLength: content.length, + userPrompt: content.length > 0, + ...(options.includeUserText && content ? { userText: content } : {}), + }]; + } + if (role === "assistant" || role === "model") { + return [{ + ...base, + type: "assistant", + category: "assistant", + evidenceRef: evidenceRef(sourceRef, "assistant"), + summary: content ? `assistant message (${content.length} chars)` : "assistant", + contentLength: content.length, + ...(content ? { userVisibleAssistantMessage: true } : {}), + }]; + } + return [{ + ...base, + type: `metadata.${role || "chat"}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${role || "chat"}`), + summary: role || "chat", + }]; +} + +async function readJsonSafe(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch { + return null; + } +} + +function addRef(sessions, sessionId, workspace, ref) { + if (!sessionId) return; + const session = sessions.get(sessionId) ?? { + sessionId, + workspace, + firstSeen: null, + lastSeen: null, + sourceKinds: new Set(), + sourceRefs: [], + workspaceCwds: new Set(), + }; + if (typeof ref.cwd === "string" && ref.cwd.length > 0) session.workspaceCwds.add(ref.cwd); + session.sourceKinds.add(ref.kind); + session.sourceRefs.push(ref); + mergeTimeRange(session, ref.firstSeen ?? ref.timestamp); + mergeTimeRange(session, ref.lastSeen ?? ref.timestamp); + sessions.set(sessionId, session); +} + +function finalizeSession(session) { + const { workspaceCwds, ...publicSession } = session; + return bindSessionWorkspaceCwds( + { ...publicSession, sourceKinds: [...session.sourceKinds].sort() }, + [...workspaceCwds], + ); +} + +async function listEncodedSessionGroups(sessionsRoot, encodedNames) { + let entries; + try { + entries = await readdir(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + const wanted = new Set(encodedNames); + return entries + .filter((entry) => entry.isDirectory() && wanted.has(entry.name)) + .map((entry) => path.join(sessionsRoot, entry.name)); +} + +export class GrokSessionAnalyzer extends SessionAnalyzer { + currentSessionId() { + return process.env.GROK_SESSION_ID ?? null; + } + + async resolveScope(options = {}) { + const since = normalizeCliDate(options.since, false); + const until = normalizeCliDate(options.until, true); + const workspace = normalizeWorkspace(options.workspace); + const workspaceMatchScope = workspaceMatchScopeFromOptions(options); + const home = path.resolve(expandHome( + options.home ?? options.grokHome ?? options["grok-home"] ?? defaultGrokHome(), + )); + const identities = [...new Set([ + workspace, + workspaceMatchScope?.requestedWorkspace, + workspaceMatchScope?.target?.kind === "workspace-member" ? workspaceMatchScope.gitRoot : null, + ].filter(Boolean))]; + return { + platform: "grok", + workspace, + home, + sessionsDir: path.join(home, "sessions"), + _encodedGroupNames: identities.map((id) => workspaceToGrokSessionDirName(id)), + since: since.label, + sinceTime: since.time, + until: until.label, + untilTime: until.time, + sessionId: options["session-id"] ?? options.sessionId ?? options._?.[0] ?? null, + includeGlobalCapabilities: parseBooleanFlag(options["include-global-capabilities"] ?? false), + _workspaceMatchScope: workspaceMatchScope, + }; + } + + async discoverSourceRoots(scope) { + const matching = await listEncodedSessionGroups(scope.sessionsDir, scope._encodedGroupNames); + return [{ + id: "grok-sessions", + kind: "grok-session-dir", + role: "session-transcript", + path: matching[0] ?? path.join(scope.sessionsDir, scope._encodedGroupNames[0] ?? ""), + paths: [scope.sessionsDir], + optional: false, + enabled: true, + workspaceScoped: true, + coverage: "primary", + exists: matching.length > 0, + }]; + } + + async discoverSessions(scope, roots) { + const sessions = new Map(); + const root = roots.find((item) => item.kind === "grok-session-dir"); + if (!root) return []; + const groups = await listEncodedSessionGroups(scope.sessionsDir, scope._encodedGroupNames); + const seen = new Set(); + for (const groupPath of groups) { + let realGroup; + try { realGroup = realpathSync.native(groupPath); } catch { realGroup = path.resolve(groupPath); } + if (seen.has(realGroup)) continue; + seen.add(realGroup); + let sessionDirs; + try { + sessionDirs = await readdir(groupPath, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of sessionDirs) { + if (!entry.isDirectory()) continue; + const sessionId = entry.name; + if (scope.sessionId && sessionId !== scope.sessionId) continue; + const sessionPath = path.join(groupPath, sessionId); + const summary = await readJsonSafe(path.join(sessionPath, "summary.json")); + const cwd = summary?.info?.cwd + ?? summary?.cwd + ?? (typeof summary?.info === "object" ? summary.info.cwd : null) + ?? null; + // Prefer summary cwd; fall back to decoding the group directory name. + let qualifiedCwd = cwd; + if (!qualifiedCwd) { + try { + qualifiedCwd = decodeURIComponent(path.basename(groupPath)); + } catch { + qualifiedCwd = null; + } + } + if (!isScopedWorkspaceMatch(qualifiedCwd, scope)) continue; + const firstSeen = normalizeTimestamp(summary?.created_at ?? summary?.createdAt ?? null); + const lastSeen = normalizeTimestamp(summary?.updated_at ?? summary?.updatedAt ?? null); + if (!withinTimeRange(lastSeen ?? firstSeen, scope)) continue; + + const updatesPath = path.join(sessionPath, "updates.jsonl"); + const historyPath = path.join(sessionPath, "chat_history.jsonl"); + const signalsPath = path.join(sessionPath, "signals.json"); + if (await pathExists(updatesPath)) { + addRef(sessions, sessionId, scope.workspace, { + kind: "grok-updates-jsonl", + role: "session-transcript", + path: updatesPath, + firstSeen, + lastSeen, + cwd: qualifiedCwd, + }); + } + if (await pathExists(historyPath)) { + addRef(sessions, sessionId, scope.workspace, { + kind: "grok-chat-history-jsonl", + role: "session-transcript-secondary", + path: historyPath, + firstSeen, + lastSeen, + cwd: qualifiedCwd, + }); + } + if (await pathExists(signalsPath)) { + addRef(sessions, sessionId, scope.workspace, { + kind: "grok-signals-json", + role: "session-usage", + path: signalsPath, + firstSeen, + lastSeen, + cwd: qualifiedCwd, + }); + } + // Ensure session appears even if only summary exists (metadata-only coverage). + if (!sessions.has(sessionId) && await pathExists(path.join(sessionPath, "summary.json"))) { + addRef(sessions, sessionId, scope.workspace, { + kind: "grok-summary-json", + role: "session-metadata", + path: path.join(sessionPath, "summary.json"), + firstSeen, + lastSeen, + cwd: qualifiedCwd, + }); + } + } + } + return [...sessions.values()].map(finalizeSession) + .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); + } + + normalizeEvent(raw, sourceRef, options = {}) { + return this.normalizeEvents(raw, sourceRef, options)[0] ?? null; + } + + normalizeEvents(raw, sourceRef, options = {}) { + if (sourceRef.kind === "grok-chat-history-jsonl") { + return chatHistoryToEvents(raw, sourceRef, options); + } + if (sourceRef.kind === "grok-signals-json") { + // Handled in readSession as a whole-file object, not line events. + return []; + } + return updatesRecordToEvents(raw, sourceRef, options); + } + + async readSession(session, scope, options = {}) { + const events = []; + const requestedMaxLines = Number(options.workspacePreflightMaxLines); + const preflight = Number.isFinite(requestedMaxLines) && requestedMaxLines > 0; + let remainingLines = preflight ? Math.trunc(requestedMaxLines) : null; + let truncated = false; + const identityCwd = sessionWorkspaceCwd(session, scope._workspaceMatchScope); + let usageEmitted = false; + + for (const ref of session.sourceRefs ?? []) { + if (remainingLines !== null && remainingLines <= 0) { + truncated = true; + break; + } + if (ref.kind === "grok-signals-json") { + const signals = await readJsonSafe(ref.path); + const usage = normalizeUsageFromSignals(signals); + if (usage && !usageEmitted) { + usageEmitted = true; + events.push({ + sessionId: session.sessionId, + timestamp: normalizeTimestamp(session.lastSeen), + sourceKind: ref.kind, + planningScope: "workspace", + cwd: identityCwd ?? ref.cwd ?? null, + type: "model.response.completed", + category: "model", + modelUsage: usage, + usageFieldsObserved: true, + evidenceRef: evidenceRef({ ...ref, sessionId: session.sessionId }, "model.response.completed"), + summary: "Grok session signals usage", + }); + } + // Missing or empty signals → leave usage unobserved (do not invent zeros). + continue; + } + if (ref.kind === "grok-summary-json") { + const summary = await readJsonSafe(ref.path); + events.push({ + sessionId: session.sessionId, + timestamp: normalizeTimestamp(summary?.created_at ?? session.firstSeen), + sourceKind: ref.kind, + planningScope: "workspace", + cwd: identityCwd ?? ref.cwd ?? null, + type: "metadata.session", + category: "metadata", + evidenceRef: evidenceRef({ ...ref, sessionId: session.sessionId }, "metadata.session"), + summary: summary?.session_summary || summary?.title || "session summary", + model: summary?.current_model_id ?? null, + }); + continue; + } + if (!ref.path.endsWith(".jsonl")) continue; + const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { + for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line, cwd: ref.cwd }, options)) { + if (withinTimeRange(event.timestamp, scope)) events.push(event); + } + }, remainingLines === null ? {} : { maxLines: remainingLines }); + if (readCoverage.invalidLines > 0) truncated = true; + if (remainingLines !== null) { + if (readCoverage.lineCount > remainingLines) truncated = true; + remainingLines -= Math.min(readCoverage.lineCount, remainingLines); + } + } + + const sorted = events + .map((event) => (event.cwd || !identityCwd ? event : { ...event, cwd: identityCwd })) + .sort((left, right) => + (timestampMillis(left.timestamp) ?? 0) - (timestampMillis(right.timestamp) ?? 0) + || Number(left.evidenceRef?.line ?? 0) - Number(right.evidenceRef?.line ?? 0) + || Number(left.evidenceRef?.seq ?? 0) - Number(right.evidenceRef?.seq ?? 0)); + return markSessionReadCoverage(sorted, { truncated }); + } + + async analyze(options = {}) { + return runProviderAnalysis(this, options, { platform: "grok", adapterVersion: "grok-v1" }); + } +} + +export async function main(argv = process.argv.slice(2)) { + const { command = "sessions", options } = parseArgs(argv); + const analyzer = new GrokSessionAnalyzer(); + const result = await runProviderCommand(analyzer, command, options); + await emitProviderResult({ provider: "Grok", command, options, result }); + return result; +} + +const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + main().catch((error) => { + process.stderr.write(`grok session-analysis failed: ${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/session-analysis/selection-profile.mjs b/scripts/session-analysis/selection-profile.mjs index 0170570..4bdb859 100644 --- a/scripts/session-analysis/selection-profile.mjs +++ b/scripts/session-analysis/selection-profile.mjs @@ -19,7 +19,7 @@ a declarative session selection plan. Raw prompts, commands, paths, and session identifiers never enter the profile. Options: - --platform + --platform Session platform (default: qoder) --workspace Target workspace (required) --since Exclude earlier sessions diff --git a/scripts/session-analysis/usage-summary.mjs b/scripts/session-analysis/usage-summary.mjs index b62dfe1..341abf1 100644 --- a/scripts/session-analysis/usage-summary.mjs +++ b/scripts/session-analysis/usage-summary.mjs @@ -12,7 +12,7 @@ Emit a bounded, read-only usage boundary as JSON. This command never accepts --output and never writes report or scratch files. Options: - --platform + --platform Session provider (default: qoder) --workspace Target workspace (default: current directory) --selection Selection strategy (default: all-eligible) diff --git a/templates/reporting/routing.md b/templates/reporting/routing.md index 88a7a1c..8630db7 100644 --- a/templates/reporting/routing.md +++ b/templates/reporting/routing.md @@ -29,6 +29,6 @@ files from other routes. | --- | --- | --- | --- | | Qoder Canvas report | Active host is Qoder | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `qoder-canvas.md` | | Cursor Canvas report | Active host is Cursor | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `cursor-canvas.md` | -| Portable HTML report | Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | +| Portable HTML report | Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, WorkBuddy, or Grok, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | | Markdown only | Markdown without a visual companion is explicitly requested | `report.md`, `findings.json` | none | | Inline only | Inline or no-files output is explicitly requested | none; inline analysis writes nothing | none | diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index a62560e..6f151ee 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -2356,3 +2356,138 @@ test("WorkBuddy provider collects user and project skills, MCP servers, and cont await rm(fixture.root, { recursive: true, force: true }); } }); + +async function makeGrokFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-grok-")); + const grokHome = path.join(root, ".grok"); + const workspace = path.join(root, "workspace"); + + await writeText( + path.join(grokHome, "config.toml"), + [ + '[mcp_servers.docs]', + 'command = "npx"', + 'args = ["-y", "docs-mcp"]', + "", + '[mcp_servers.disabled_search]', + "enabled = false", + 'url = "https://example.invalid/mcp"', + "", + ].join("\n"), + ); + await writeText( + path.join(grokHome, "skills", "frontend-slides", "SKILL.md"), + "---\nname: frontend-slides\ndescription: Build HTML slide decks.\n---\n", + ); + await writeText( + path.join(grokHome, "bundled", "skills", "help", "SKILL.md"), + "---\nname: help\ndescription: Grok help skill.\n---\n", + ); + await writeJson(path.join(grokHome, "hooks", "git-gh-only.json"), { + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [ + { type: "command", command: "echo start" }, + ], + }, + ], + }, + }); + + const pluginRoot = path.join(grokHome, "installed-plugins", "sample-plugin"); + await writeJson(path.join(pluginRoot, "plugin.json"), { + name: "sample-plugin", + displayName: "Sample Plugin", + description: "A sample Grok plugin.", + }); + await writeText( + path.join(pluginRoot, "skills", "sample-skill", "SKILL.md"), + "---\nname: sample-skill\ndescription: Sample plugin skill.\n---\n", + ); + + await writeText( + path.join(workspace, ".grok", "skills", "project-flow", "SKILL.md"), + "---\nname: project-flow\ndescription: Project Grok workflow.\n---\n", + ); + await writeText( + path.join(workspace, ".agents", "skills", "shared-standard", "SKILL.md"), + "---\nname: shared-standard\ndescription: Shared Agent Skills standard workflow.\n---\n", + ); + await writeText(path.join(workspace, "AGENTS.md"), "# Grok Project Instructions\n"); + + return { root, grokHome, workspace }; +} + +test("collectAgentCustomizeInventory returns Grok skills, MCP, hooks, and installed plugins", async () => { + const fixture = await makeGrokFixture(); + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "grok", + grokHome: fixture.grokHome, + workspace: fixture.workspace, + }); + + assert.equal(inventory.provider, "grok"); + assert.equal(inventory.grokHome, fixture.grokHome); + assert.equal(inventory.plugins.length, 1); + assert.equal(inventory.plugins[0].name, "sample-plugin"); + assert.equal(inventory.plugins[0].installMatch, "grok-installed-plugins-dir"); + assert.deepEqual(inventory.plugins[0].skills.map((skill) => skill.name), ["sample-skill"]); + + assert.ok( + filterManageItems(inventory, { tab: "skills", scopeKind: "user" }) + .some((item) => item.name === "frontend-slides"), + ); + assert.ok( + filterManageItems(inventory, { tab: "skills", scopeKind: "user" }) + .some((item) => item.name === "help"), + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "skills", scopeKind: "project" }).map((item) => item.name).sort(), + ["project-flow", "shared-standard"], + ); + assert.ok( + filterManageItems(inventory, { tab: "mcps", scopeKind: "user" }) + .some((item) => item.name === "docs" && item.enabled !== false), + ); + const disabled = filterManageItems(inventory, { tab: "mcps", scopeKind: "user" }) + .find((item) => item.name === "disabled_search"); + assert.ok(disabled); + assert.equal(disabled.enabled, false); + assert.ok( + filterManageItems(inventory, { tab: "hooks", scopeKind: "user" }) + .some((item) => (item.label ?? item.name ?? "").includes("PreToolUse") + || item.evidence?.path?.includes("git-gh-only")), + ); + assert.equal(inventory.diagnostics.configPath, path.join(fixture.grokHome, "config.toml")); + // Inventory may mention secret *policy* labels; values themselves must stay absent. + assert.doesNotMatch(JSON.stringify(inventory), /sk-[A-Za-z0-9]{10,}|Bearer\s+[A-Za-z0-9._-]+/u); + assert.ok(inventory.unsupported.some((item) => /auth\.json/u.test(item))); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("agent-customize CLI honours --grok-home instead of the real user home", async () => { + const fixture = await makeGrokFixture(); + try { + const result = runAgentCustomizeCli([ + "inventory", + "--provider", + "grok", + "--workspace", + fixture.workspace, + "--grok-home", + fixture.grokHome, + ]); + assert.equal(result.status, 0, result.stderr); + const inventory = JSON.parse(result.stdout); + assert.equal(inventory.grokHome, fixture.grokHome); + assert.notEqual(inventory.grokHome, path.join(os.homedir(), ".grok")); + assert.ok(inventory.manage.mcps.some((item) => item.name === "docs")); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index a86f0ed..8cfb5a3 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -116,8 +116,9 @@ function leadEvidence(overrides = {}) { } test("evidence-bundle help advertises WorkBuddy and its isolated home override", () => { - assert.match(EVIDENCE_BUNDLE_HELP, /pi, or workbuddy/u); + assert.match(EVIDENCE_BUNDLE_HELP, /pi, workbuddy, or grok/u); assert.match(EVIDENCE_BUNDLE_HELP, /--workbuddy-home /u); + assert.match(EVIDENCE_BUNDLE_HELP, /--grok-home /u); }); function topologyResolution(workspace = ".", status = "complete") { diff --git a/test/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index db22f99..51b9751 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -46,7 +46,8 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.copilot/session-state", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.pi/agent/sessions", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.workbuddy/projects", "## Platform Notes", "Sessions Diagnostics"); - assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, and `workbuddy`/); + assertAfter(content, "~/.grok/sessions", "## Platform Notes", "Sessions Diagnostics"); + assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, `workbuddy`, and `grok`/); assert.match(content, /Never decode Cursor `store\.db`/); assert.ok(content.indexOf("session-analysis.mjs sources") < content.indexOf("## Platform Notes")); }); diff --git a/test/docs-entrypoints.test.mjs b/test/docs-entrypoints.test.mjs index a1c887d..f5ebe0b 100644 --- a/test/docs-entrypoints.test.mjs +++ b/test/docs-entrypoints.test.mjs @@ -20,6 +20,7 @@ const PUBLIC_QUICKSTART_HOSTS = [ const ADAPTER_SUPPORT_HOSTS = [ { name: "Pi", anchor: "pi", id: "pi" }, { name: "WorkBuddy", anchor: "workbuddy", id: "workBuddy" }, + { name: "Grok", anchor: "grok", id: "grok" }, ]; const SUPPORTED_CARD_HOSTS = [ @@ -139,8 +140,8 @@ test("README.md Quickstart lists all seven product entries with valid installati content, /qoderai\.github\.io\/better-harness\/\?utm_source=github&utm_medium=referral&utm_campaign=repository_landing&utm_content=readme_hero/u, ); - assert.match(content, /canonical registry covers eight host adapters/u); - assert.match(content, /Pi and WorkBuddy currently\nremain adapter-support entries/u); + assert.match(content, /canonical registry covers nine host adapters/u); + assert.match(content, /Pi, WorkBuddy, and Grok currently\nremain adapter-support entries/u); }); test("README.zh-CN.md Quickstart lists all seven product entries with valid installation anchors", () => { @@ -165,11 +166,11 @@ test("README.zh-CN.md Quickstart lists all seven product entries with valid inst content, /https:\/\/qoderai\.github\.io\/better-harness\/demo\/better-harness-report\//u, ); - assert.match(content, /规范注册表当前包含八个宿主适配器/u); - assert.match(content, /Pi 与 WorkBuddy 仍属于适配器支持入口/u); + assert.match(content, /规范注册表当前包含九个宿主适配器/u); + assert.match(content, /Pi、WorkBuddy 与 Grok 仍属于适配器支持入口/u); }); -test("Docusaurus home page cards expose eight adapters without flattening support levels", () => { +test("Docusaurus home page cards expose nine adapters without flattening support levels", () => { const source = readUtf8("docs", "src", "pages", "index.js"); const anchors = []; @@ -266,7 +267,7 @@ test("zh-Hans installation.mdx tabs match public Quickstart host anchors", () => } }); -test("zh-Hans code.json has translations for all eight homepage host cards", () => { +test("zh-Hans code.json has translations for all nine homepage host cards", () => { const codeJson = JSON.parse(readUtf8("docs", "i18n", "zh-Hans", "code.json")); for (const host of SUPPORTED_CARD_HOSTS) { for (const field of ["method", "setup"]) { @@ -283,7 +284,7 @@ test("zh-Hans code.json has translations for all eight homepage host cards", () assert.ok(codeJson["homepage.hosts.status.adapter"]?.message); }); -test("public adapter matrix documents all eight adapters and their support boundaries", () => { +test("public adapter matrix documents all nine adapters and their support boundaries", () => { const matrix = readUtf8("docs", "docs", "hosts", "adapter-matrix.md"); const tableRows = matrix .split("\n") @@ -300,15 +301,16 @@ test("public adapter matrix documents all eight adapters and their support bound assert.match( matrix, - /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy/u, + /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy\/Grok/u, "docs/docs/hosts/adapter-matrix.md HTML visual contract omits supported HTML hosts", ); assert.match(matrix, /\*\*Cursor Canvas\*\*[^]*`cursor\/canvas`/u); assert.match(matrix, /### Pi \{#pi\}/u); assert.match(matrix, /### WorkBuddy \{#workbuddy\}/u); + assert.match(matrix, /### Grok \{#grok\}/u); }); -test("zh-Hans public adapter matrix documents all eight adapters and their support boundaries", () => { +test("zh-Hans public adapter matrix documents all nine adapters and their support boundaries", () => { const matrix = readUtf8("docs", "i18n", "zh-Hans", "docusaurus-plugin-content-docs", "current", "hosts", "adapter-matrix.md"); const tableRows = matrix .split("\n") @@ -325,12 +327,13 @@ test("zh-Hans public adapter matrix documents all eight adapters and their suppo assert.match( matrix, - /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy/u, + /Claude Code\/Codex\/Qwen\/Copilot\/Pi\/WorkBuddy\/Grok/u, "zh-Hans adapter-matrix.md HTML visual contract omits supported HTML hosts", ); assert.match(matrix, /\*\*Cursor Canvas\*\*[^]*`cursor\/canvas`/u); assert.match(matrix, /### Pi \{#pi\}/u); assert.match(matrix, /### WorkBuddy \{#workbuddy\}/u); + assert.match(matrix, /### Grok \{#grok\}/u); }); test("installation pages connect missing-host developers to matrices and pull requests", () => { diff --git a/test/docs-site.test.mjs b/test/docs-site.test.mjs index 24e6b4e..cdcdadf 100644 --- a/test/docs-site.test.mjs +++ b/test/docs-site.test.mjs @@ -186,7 +186,7 @@ test("homepage leads search visitors from proof to a host-specific setup", async assert.match(source, /Verified Quickstart/u); assert.match(source, /Adapter support/u); assert.equal([...source.matchAll(/supportLevel: "quickstart"/gu)].length, 6); - assert.equal([...source.matchAll(/supportLevel: "adapter"/gu)].length, 2); + assert.equal([...source.matchAll(/supportLevel: "adapter"/gu)].length, 3); assert.doesNotMatch( source, /View live demo report|REPORT_PROMPT|CodeBlock|\/better-harness<\/code>/u, @@ -265,7 +265,7 @@ test("homepage leads search visitors from proof to a host-specific setup", async assert.match(zh["homepage.demo.historyCaption"].message, /静态最终帧/u); }); -test("architecture and public matrices explain the eight/six/two support boundary", async () => { +test("architecture and public matrices explain the nine/six/three support boundary", async () => { const [architecture, matrix, matrixZh] = await Promise.all([ readFile( path.join(process.cwd(), "assets", "better-harness-architecture-en.svg"), @@ -290,18 +290,18 @@ test("architecture and public matrices explain the eight/six/two support boundar ), ]); - assert.match(architecture, /8 CAPABILITY ADAPTERS/u); + assert.match(architecture, /9 CAPABILITY ADAPTERS/u); assert.match(architecture, /6 verified Quickstart hosts/u); - assert.match(architecture, /Pi \+ WorkBuddy adapters/u); + assert.match(architecture, /Pi \+ WorkBuddy \+ Grok adapters/u); assert.match(architecture, /Qoder Canvas · portable HTML/u); assert.match(architecture, /Better Harness Skill Workflow/u); assert.doesNotMatch(architecture, />\/better-harness<\/text>/u); assert.doesNotMatch(architecture, /Claude · Codex · Qoder · Cursor<\/text>/u); - assert.match(matrix, /eight capability-level host adapters/u); + assert.match(matrix, /nine capability-level host adapters/u); assert.match(matrix, /Six\nhave verified public Quickstart paths/u); - assert.match(matrix, /Pi and WorkBuddy are visible as adapter\nsupport/u); - assert.match(matrixZh, /八个能力层宿主适配器/u); + assert.match(matrix, /Pi, WorkBuddy, and Grok are visible as adapter\nsupport/u); + assert.match(matrixZh, /九个能力层宿主适配器/u); assert.match(matrixZh, /六个已有验证过的公开\n快速开始路径/u); - assert.match(matrixZh, /Pi 与 WorkBuddy 以适配器支持展示/u); + assert.match(matrixZh, /Pi、WorkBuddy 与 Grok 以适配器支持展示/u); }); diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index 8c84a07..1fbb088 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -35,6 +35,10 @@ import { WorkbuddySessionAnalyzer, workspaceToWorkbuddySlugVariants, } from "../scripts/session-analysis/platforms/workbuddy.mjs"; +import { + GrokSessionAnalyzer, + workspaceToGrokSessionDirName, +} from "../scripts/session-analysis/platforms/grok.mjs"; import { CopilotSessionAnalyzer, parseWorkspaceDescriptor, @@ -60,12 +64,14 @@ test("root dispatcher creates Claude and Cursor provider analyzers", async () => assert.ok(await createAnalyzer("copilot") instanceof CopilotSessionAnalyzer); assert.ok(await createAnalyzer("pi") instanceof PiSessionAnalyzer); assert.ok(await createAnalyzer("workbuddy") instanceof WorkbuddySessionAnalyzer); + assert.ok(await createAnalyzer("grok") instanceof GrokSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("claude") instanceof ClaudeSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("cursor") instanceof CursorSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("qwen") instanceof QwenSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("copilot") instanceof CopilotSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("pi") instanceof PiSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("workbuddy") instanceof WorkbuddySessionAnalyzer); + assert.ok(await createCapabilityAnalyzer("grok") instanceof GrokSessionAnalyzer); }); test("public session-analysis main preserves the bare help alias", async () => { @@ -88,6 +94,7 @@ test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", assert.ok(workspaceToQwenSlugVariants("/workspace/project").includes("-workspace-project")); assert.equal(workspaceToPiSessionDirVariants("/workspace/project").exact, "--workspace-project--"); assert.equal(workspaceToWorkbuddySlugVariants("/workspace/project").exact, "workspace-project"); + assert.equal(workspaceToGrokSessionDirName("/workspace/project"), encodeURIComponent("/workspace/project")); assert.ok(workspaceToClaudeSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToCursorSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToQwenSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); @@ -1282,3 +1289,169 @@ test("WorkBuddy source roots stay absent without workspace-matching project dire assert.equal(result.sources[0].exists, false); assert.equal(result.sessions.length, 0); }); + +test("Grok provider expands user/assistant/tool events and optional signals usage", async () => { + const root = await fixtureRoot("session-grok-provider-"); + const home = path.join(root, ".grok"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const group = workspaceToGrokSessionDirName(workspace); + const sessionDir = path.join(home, "sessions", group, sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "summary.json"), JSON.stringify({ + info: { id: sessionId, cwd: workspace }, + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:05:00.000Z", + current_model_id: "grok-4", + }, null, 2)); + await writeJsonl(path.join(sessionDir, "updates.jsonl"), [ + { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { text: "Implement the Grok provider" }, + _meta: { isoTimestamp: "2026-08-01T10:00:01.000Z" }, + }, + }, + }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolName: "run_terminal_command", + toolCallId: "call_1", + status: "pending", + _meta: { isoTimestamp: "2026-08-01T10:00:02.000Z" }, + }, + }, + }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call_update", + toolName: "run_terminal_command", + toolCallId: "call_1", + status: "completed", + content: "ok", + _meta: { isoTimestamp: "2026-08-01T10:00:03.000Z" }, + }, + }, + }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { text: "Done." }, + _meta: { isoTimestamp: "2026-08-01T10:00:04.000Z", modelId: "grok-4" }, + }, + }, + }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "thought_chunk", + content: { text: "thinking" }, + _meta: { isoTimestamp: "2026-08-01T10:00:05.000Z" }, + }, + }, + }, + ]); + await writeFile(path.join(sessionDir, "signals.json"), JSON.stringify({ + inputTokens: 120, + outputTokens: 40, + totalTokens: 160, + }, null, 2)); + + const analyzer = new GrokSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.equal(discovery.sessions[0].sessionId, sessionId); + assert.deepEqual(discovery.sources.map((source) => source.kind), ["grok-session-dir"]); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { + includeUserText: true, + includeContent: true, + }); + assert.equal(events.filter((event) => event.type === "user").length, 1); + assert.equal(events.find((event) => event.type === "user")?.userText, "Implement the Grok provider"); + assert.equal(events.filter((event) => event.type === "tool.call").length, 1); + assert.equal(events.filter((event) => event.type === "tool.result").length, 1); + assert.equal(events.filter((event) => event.type === "assistant").length, 1); + assert.ok(events.some((event) => event.type === "metadata.thought_chunk")); + const usage = events.find((event) => event.type === "model.response.completed"); + assert.equal(usage?.modelUsage?.inputTokens, 120); + assert.equal(usage?.modelUsage?.outputTokens, 40); + const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); + assert.equal(facts.kind, "session-core-facts"); + assert.equal(facts.scope.platform, "grok"); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(sessionId, "u")); +}); + +test("Grok provider excludes foreign workspace session groups", async () => { + const root = await fixtureRoot("session-grok-isolation-"); + const home = path.join(root, ".grok"); + const workspace = path.join(root, "workspace", "target"); + const foreign = path.join(root, "workspace", "other"); + const sessionId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const foreignGroup = workspaceToGrokSessionDirName(foreign); + const sessionDir = path.join(home, "sessions", foreignGroup, sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "summary.json"), JSON.stringify({ + info: { id: sessionId, cwd: foreign }, + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:05:00.000Z", + }, null, 2)); + await writeJsonl(path.join(sessionDir, "updates.jsonl"), [ + { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { text: "foreign" }, + _meta: { isoTimestamp: "2026-08-01T10:00:01.000Z" }, + }, + }, + }, + ]); + const result = await new GrokSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 0); + assert.equal(result.sources[0].exists, false); +}); + +test("Grok provider leaves usage unobserved when signals.json is missing", async () => { + const root = await fixtureRoot("session-grok-no-signals-"); + const home = path.join(root, ".grok"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const group = workspaceToGrokSessionDirName(workspace); + const sessionDir = path.join(home, "sessions", group, sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "summary.json"), JSON.stringify({ + info: { id: sessionId, cwd: workspace }, + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:05:00.000Z", + }, null, 2)); + await writeJsonl(path.join(sessionDir, "updates.jsonl"), [ + { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { text: "no signals" }, + _meta: { isoTimestamp: "2026-08-01T10:00:01.000Z" }, + }, + }, + }, + ]); + const analyzer = new GrokSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, {}); + assert.equal(events.filter((event) => event.type === "model.response.completed").length, 0); + assert.ok(!events.some((event) => event.modelUsage && Object.values(event.modelUsage).every((value) => value === 0))); +}); diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index 14136d2..ff2b629 100644 --- a/test/support-declarations.test.mjs +++ b/test/support-declarations.test.mjs @@ -9,7 +9,7 @@ import { createAnalyzer, SESSION_ANALYSIS_HELP } from "../scripts/session-analys // Canonical support declaration (roadmap A-06): CLI help, provider registry, // session platforms, report platforms, and docs must all agree on this set. -const SUPPORTED_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"]; +const SUPPORTED_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]; const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); const adapterMatrixPath = path.join(process.cwd(), "docs", "adapters", "README.md"); @@ -156,8 +156,8 @@ test("adapter-matrix portable HTML hosts appear in the portable HTML report rout } const prefixCollisionRouting = routing.replace( - ", or WorkBuddy, or a portable visual is explicitly requested", - ", or WorkBuddy Enterprise, or a portable visual is explicitly requested", + ", WorkBuddy, or Grok, or a portable visual is explicitly requested", + ", WorkBuddy Enterprise, or Grok, or a portable visual is explicitly requested", ); assert.notEqual(prefixCollisionRouting, routing, "prefix-collision fixture did not replace the WorkBuddy route entry"); assert.deepEqual(missingPortableHtmlRouteHosts(matrix, prefixCollisionRouting), ["WorkBuddy"]); From 9d6db9ce216676f45adb11336ec5626c83a03265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Sun, 2 Aug 2026 21:27:36 +0800 Subject: [PATCH 2/6] fix(hosts): unbreak CI freezes after Grok adapter registration Update frozen help fixtures, CLI registry summary, portable-HTML routing assertions, and Windows-portable Grok session path encoding checks. --- scripts/better-harness-cli/registry.mjs | 2 +- scripts/session-analysis/analyzer.mjs | 2 +- test/better-harness-skill.test.mjs | 2 +- test/fixtures/scripts-refactor-contract/root-help.txt | 2 +- .../scripts-refactor-contract/session-help.txt | 4 ++-- test/scripts-refactor-contract.test.mjs | 4 ++-- test/session-analysis-providers.test.mjs | 10 +++++++++- test/style-templates.test.mjs | 2 +- 8 files changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 2a589ca..831ef18 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -52,7 +52,7 @@ const COMMANDS = [ kind: "direct", audience: "advanced", script: "session-analysis.mjs", - summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, and WorkBuddy session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, WorkBuddy, and Grok session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/session-analysis/analyzer.mjs b/scripts/session-analysis/analyzer.mjs index 17368fb..f2ff2b3 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -316,7 +316,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { ...eventOptions, ...claudeOptions, "", - "Options: --workbuddy-home overrides the WorkBuddy data root (default: ~/.workbuddy).", + "Options: --workbuddy-home overrides the WorkBuddy data root (default: ~/.workbuddy); --grok-home overrides the Grok data root (default: ~/.grok or $GROK_HOME).", "", "Use facts --debug only for local diagnosis; it exposes raw session ids and must not be passed to report agents.", ].join("\n") + "\n"); diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 5bab840..ff07d69 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -94,7 +94,7 @@ test("Qoder and Cursor default to Canvas while portable providers retain durable assert.match(skill, /Never hand-write\s+Canvas, Markdown, or HTML/); assert.match( routing, - /Portable HTML report \| Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, + /Portable HTML report \| Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, WorkBuddy, or Grok, or a portable visual is explicitly requested \|/, ); assert.match(routing, /Cursor Canvas report \| Active host is Cursor \|/); assert.match(routing, /Inline only \| Inline or no-files output is explicitly requested \| none; inline analysis writes nothing/); diff --git a/test/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 89dd275..1fc830e 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -22,7 +22,7 @@ Commands: Project Evidence session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, - Pi, and WorkBuddy session evidence + Pi, WorkBuddy, and Grok session evidence dependency-governance Detect dependency governance files, automation, audit signals, and stale dependency evidence cloc Count code, comments, and blank lines diff --git a/test/fixtures/scripts-refactor-contract/session-help.txt b/test/fixtures/scripts-refactor-contract/session-help.txt index 8136ed1..ae81ce9 100644 --- a/test/fixtures/scripts-refactor-contract/session-help.txt +++ b/test/fixtures/scripts-refactor-contract/session-help.txt @@ -1,7 +1,7 @@ -Usage: session-analysis --platform --workspace [options] +Usage: session-analysis --platform --workspace [options] Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets -Options: --workbuddy-home overrides the WorkBuddy data root (default: ~/.workbuddy). +Options: --workbuddy-home overrides the WorkBuddy data root (default: ~/.workbuddy); --grok-home overrides the Grok data root (default: ~/.grok or $GROK_HOME). Use facts --debug only for local diagnosis; it exposes raw session ids and must not be passed to report agents. diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index ae1160a..29b0053 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -107,12 +107,12 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "7ed9c1399e89ca29c888ab17e04cb023d3f15f7dfc819a989de0015ed19a87bf", + sha256: "4c3779a9cf5d7caa32d754c1463f0d734e8f6db717a4d32fc40f8bef09283176", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "4afc57251605333b3a9fc1ccb244fb6a60a34a7ed9d5cabde307fe636ba1e0a5", + sha256: "1ab508b17645fe84df492e58386cdab505f5b3a525edbf63b9bfa2f34d9a373b", }, { label: "Harness command description", diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index 1fbb088..88840c7 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -94,12 +94,20 @@ test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", assert.ok(workspaceToQwenSlugVariants("/workspace/project").includes("-workspace-project")); assert.equal(workspaceToPiSessionDirVariants("/workspace/project").exact, "--workspace-project--"); assert.equal(workspaceToWorkbuddySlugVariants("/workspace/project").exact, "workspace-project"); - assert.equal(workspaceToGrokSessionDirName("/workspace/project"), encodeURIComponent("/workspace/project")); + // Grok encodes path.resolve()'d absolute paths; keep the assertion host-portable. + assert.equal( + workspaceToGrokSessionDirName("/workspace/project"), + encodeURIComponent(path.resolve("/workspace/project")), + ); assert.ok(workspaceToClaudeSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToCursorSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToQwenSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToPiSessionDirVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); assert.ok(workspaceToWorkbuddySlugVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); + assert.equal( + workspaceToGrokSessionDirName("C:\\workspace\\project"), + encodeURIComponent(path.resolve("C:\\workspace\\project")), + ); }); test("Claude workspace slug folds dots the way Claude Code names project directories", () => { diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index 3b31a13..f06d389 100644 --- a/test/style-templates.test.mjs +++ b/test/style-templates.test.mjs @@ -78,7 +78,7 @@ test("harness report routing owns output-mode selection and exclusions", () => { assert.match(reportRouting, /Portable HTML report/); assert.match( reportRouting, - /Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, or WorkBuddy, or a portable visual is explicitly requested \|/, + /Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, WorkBuddy, or Grok, or a portable visual is explicitly requested \|/, ); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); From fe4739fb9cf6f77fb311fb2c44dc9c15c90e7fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Sun, 2 Aug 2026 21:38:33 +0800 Subject: [PATCH 3/6] fix(grok): dedupe project skills when .grok/skills is a symlink OnMyAgent keeps SoT under .agents/skills and links .grok/skills to it. Inventory previously listed both paths, inflating owner routes past the 16-entry baseline and marking agentCustomize partial for normal depth. --- scripts/agent-customize/providers/grok.mjs | 38 +++++++++++++++++++-- test/agent-customize.test.mjs | 39 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/scripts/agent-customize/providers/grok.mjs b/scripts/agent-customize/providers/grok.mjs index 8ef8610..9503877 100644 --- a/scripts/agent-customize/providers/grok.mjs +++ b/scripts/agent-customize/providers/grok.mjs @@ -4,7 +4,7 @@ */ import os from "node:os"; import path from "node:path"; -import { readdir } from "node:fs/promises"; +import { readdir, realpath } from "node:fs/promises"; import { expandHome, normalizeWorkspace, pathExists } from "../../session-analysis/index.mjs"; import { MANAGE_TABS } from "../constants.mjs"; @@ -180,6 +180,40 @@ async function collectGrokUserPrimitives(grokHome) { }; } +async function resolveRealPath(filePath) { + try { + return await realpath(filePath); + } catch { + return path.resolve(filePath); + } +} + +/** + * Merge project skills from `.grok/skills` and `.agents/skills`. + * When `.grok/skills` is a symlink to `.agents/skills` (OnMyAgent layout), keep + * a single entry and prefer the `.agents` SoT path for evidence/owner routes. + */ +export async function mergeGrokProjectSkills(skills, workspace) { + const agentsRoot = path.resolve(workspace, ".agents", "skills"); + const byReal = new Map(); + for (const skill of skills) { + const filePath = skill?.filePath ?? skill?.path; + if (!filePath) continue; + const key = await resolveRealPath(filePath); + const existing = byReal.get(key); + if (!existing) { + byReal.set(key, skill); + continue; + } + const existingPath = existing.filePath ?? existing.path ?? ""; + const preferAgents = + String(filePath).startsWith(agentsRoot) + && !String(existingPath).startsWith(agentsRoot); + if (preferAgents) byReal.set(key, skill); + } + return [...byReal.values()].sort(sortByName); +} + async function collectGrokWorkspacePrimitives(workspace) { const sourceLabel = await workspaceSourceLabel(workspace); const projectRoot = path.join(workspace, ".grok"); @@ -193,7 +227,7 @@ async function collectGrokWorkspacePrimitives(workspace) { const projectMcps = await collectMcpItems(workspace, "project", sourceLabel, workspace); return { ...project, - skills: [...project.skills, ...agentsSkills].sort(sortByName), + skills: await mergeGrokProjectSkills([...project.skills, ...agentsSkills], workspace), mcps: [...project.mcps, ...projectMcps].sort(sortByName), rules: [ ...project.rules, diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 6f151ee..ca3dbaf 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -2491,3 +2491,42 @@ test("agent-customize CLI honours --grok-home instead of the real user home", as await rm(fixture.root, { recursive: true, force: true }); } }); + +test("Grok project skills dedupe when .grok/skills symlinks to .agents/skills", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-grok-skill-symlink-")); + const grokHome = path.join(root, "home", ".grok"); + const workspace = path.join(root, "workspace"); + try { + await mkdir(path.join(grokHome), { recursive: true }); + await writeText( + path.join(workspace, ".agents", "skills", "shared-flow", "SKILL.md"), + "---\nname: shared-flow\ndescription: Shared SoT skill.\n---\n", + ); + await mkdir(path.join(workspace, ".grok"), { recursive: true }); + await symlink( + path.join(workspace, ".agents", "skills"), + path.join(workspace, ".grok", "skills"), + "dir", + ); + + const inventory = await collectAgentCustomizeInventory({ + provider: "grok", + grokHome, + workspace, + includeUserHome: false, + }); + const projectSkills = filterManageItems(inventory, { + tab: "skills", + scopeKind: "project", + }); + assert.deepEqual( + projectSkills.map((item) => item.name).sort(), + ["shared-flow"], + ); + assert.equal(projectSkills.length, 1); + const evidencePath = projectSkills[0]?.evidence?.path ?? projectSkills[0]?.filePath ?? ""; + assert.match(evidencePath.replace(/\\/gu, "/"), /\.agents\/skills\/shared-flow\/SKILL\.md$/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From f0062dbf308e7053289f4a8385a41033e7d2583a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Mon, 3 Aug 2026 09:32:13 +0800 Subject: [PATCH 4/6] fix(grok): align adapter with Grok 0.2.x native evidence Address PR #52 review feedback from maintainer validation: - Inventory plugins from ~/.grok/plugins, project .grok/plugins, and config paths; load project .grok/config.toml MCP; include ~/.agents/skills; avoid double-counting when workspace is the user home. - Treat only terminal tool_call_update statuses as results; map turn_completed.usage for token spend; never use contextTokensUsed as total. - Prefer updates.jsonl over chat_history; discover long-path session groups via .cwd markers. - Accept --platform/--provider on harness render for .grok/better-harness; cycle-safe walkFiles; homepage Nine copy; spec smoke path cleanup. Co-authored-by: Grok (Grok 4.5) --- docs/specs/2026-08-02-grok-host-adapter.md | 26 ++- docs/src/pages/index.js | 4 +- scripts/agent-customize/providers/grok.mjs | 178 +++++++++++++--- scripts/harness-analysis/render-report.mjs | 12 +- scripts/session-analysis/fs.mjs | 16 +- scripts/session-analysis/platforms/grok.mjs | 224 ++++++++++++++++---- test/agent-customize.test.mjs | 18 +- test/session-analysis-providers.test.mjs | 90 +++++++- 8 files changed, 479 insertions(+), 89 deletions(-) diff --git a/docs/specs/2026-08-02-grok-host-adapter.md b/docs/specs/2026-08-02-grok-host-adapter.md index 9b8b930..d0e2336 100644 --- a/docs/specs/2026-08-02-grok-host-adapter.md +++ b/docs/specs/2026-08-02-grok-host-adapter.md @@ -1,9 +1,9 @@ # Spec: Grok CLI host adapter -**Date:** 2026-08-02 -**Host id:** `grok` -**Host version (verified):** Grok CLI 0.2.x (user-guide + local `~/.grok`) -**Capability level (this PR):** Partial adapter → Verified assets + sessions + HTML render path +**Date:** 2026-08-02 +**Host id:** `grok` +**Host version (verified):** Grok CLI 0.2.x (user-guide + local `~/.grok`) +**Capability level (this PR):** Partial adapter → Verified assets + sessions + HTML render path **Non-goals:** Grok marketplace packaging into public npm shell; Canvas mode; reading `auth.json` secrets; claiming full Quickstart until native install smoke is recorded. ## Support slices @@ -33,6 +33,20 @@ ### Workspace qualification +' + ) +# add notes section if missing +if 'updates.jsonl is authoritative' not in t: + t = t.replace( + '### Privacy +', + - `updates.jsonl` is the authoritative conversation log; `chat_history.jsonl` is only used when updates are missing (never both). +- Terminal tool results require an explicit terminal status (`completed`/`failed`/…); progress and status-less `tool_call_update` stay metadata. +- Model usage comes from `turn_completed.usage` on `_x.ai/session/update` records. `signals.contextTokensUsed` is context occupancy, not total spend. +- When `encodeURIComponent(cwd)` exceeds 255 bytes, Grok uses a slug+hash group directory and stores the original path in `.cwd`; discovery matches both forms. + +### Privacy + - Prefer `summary.json` → `info.cwd` (or equivalent) matched via existing workspace-match helpers. - Session group directory name is `encodeURIComponent(absoluteCwd)` (e.g. `/Users/work` → `%2FUsers%2Fwork`). - Foreign-workspace sessions never enter facts for a report. @@ -58,8 +72,8 @@ ## Smoke (local) ```bash -node scripts/session-analysis/analyzer.mjs sources --platform grok --workspace -node scripts/agent-customize/cli.mjs inventory --provider grok --workspace +node scripts/session-analysis.mjs sources --platform grok --workspace +node scripts/better-harness.mjs agent-customize inventory --provider grok --workspace node scripts/better-harness.mjs harness evidence-bundle --platform grok --workspace --depth quick --format json ``` diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js index 51b3e46..3ddd28b 100644 --- a/docs/src/pages/index.js +++ b/docs/src/pages/index.js @@ -509,7 +509,7 @@ function HowItWorks() {

- Eight capability-level host adapters feed the same evidence + Nine capability-level host adapters feed the same evidence pipeline. Six have verified Quickstart paths; Pi, WorkBuddy, and Grok keep their current adapter-support boundaries explicit. @@ -533,7 +533,7 @@ function QuickStart() {

- Eight host adapters are supported. Six have verified setup paths; + Nine host adapters are supported. Six have verified setup paths; Pi, WorkBuddy, and Grok link to their current support boundaries.

diff --git a/scripts/agent-customize/providers/grok.mjs b/scripts/agent-customize/providers/grok.mjs index 9503877..64adb42 100644 --- a/scripts/agent-customize/providers/grok.mjs +++ b/scripts/agent-customize/providers/grok.mjs @@ -106,7 +106,62 @@ async function collectGrokMcpFromConfig(configPath, scope, sourceLabel, rootForE })).sort(sortByName); } -async function collectInstalledPlugins(pluginsRoot) { +/** + * Parse [plugins] paths/enabled/disabled from config.toml (minimal TOML). + */ +export function parseGrokPluginsSectionFromToml(text) { + if (typeof text !== "string" || text.trim() === "") { + return { paths: [], enabled: [], disabled: [] }; + } + const lines = text.split(/\r?\n/u); + let inPlugins = false; + const result = { paths: [], enabled: [], disabled: [] }; + let multiKey = null; + let multiBuf = ""; + const flushMulti = () => { + if (!multiKey) return; + const raw = multiBuf.replace(/\s+/gu, " ").trim(); + const items = [...raw.matchAll(/"([^"]+)"|'([^']+)'/gu)].map((m) => m[1] ?? m[2]); + result[multiKey] = items; + multiKey = null; + multiBuf = ""; + }; + for (const rawLine of lines) { + const line = rawLine.replace(/#.*$/u, "").trim(); + if (!line) continue; + if (/^\[plugins\]$/u.test(line)) { + flushMulti(); + inPlugins = true; + continue; + } + if (/^\[/u.test(line)) { + flushMulti(); + inPlugins = false; + continue; + } + if (!inPlugins) continue; + if (multiKey) { + multiBuf += ` ${line}`; + if (line.includes("]")) flushMulti(); + continue; + } + const kv = line.match(/^(paths|enabled|disabled)\s*=\s*(.+)$/u); + if (!kv) continue; + const key = kv[1]; + const value = kv[2].trim(); + if (value.startsWith("[") && !value.includes("]")) { + multiKey = key; + multiBuf = value; + continue; + } + const items = [...value.matchAll(/"([^"]+)"|'([^']+)'/gu)].map((m) => m[1] ?? m[2]); + result[key] = items; + } + flushMulti(); + return result; +} + +async function collectPluginsFromRoot(pluginsRoot, installMatch, enabledSet, disabledSet) { if (!(await pathExists(pluginsRoot))) return []; const plugins = []; for (const pluginRoot of await listDirectories(pluginsRoot)) { @@ -125,23 +180,33 @@ async function collectInstalledPlugins(pluginsRoot) { break; } } + const pluginName = manifest?.name || name; const displayName = normalizePluginDisplayName( - manifest?.displayName || manifest?.name || titleCase(name), + manifest?.displayName || pluginName || titleCase(name), name, ); + // Grok: plugins are off by default unless listed in enabled (or no lists at all → discover as enabled for trusted user roots). + let enabled = true; + if (enabledSet.size > 0 || disabledSet.size > 0) { + enabled = enabledSet.has(pluginName) || enabledSet.has(name); + if (disabledSet.has(pluginName) || disabledSet.has(name)) enabled = false; + if (enabledSet.size === 0 && disabledSet.size > 0) { + enabled = !(disabledSet.has(pluginName) || disabledSet.has(name)); + } + } const plugin = { - id: `grok/installed/${name}`, + id: `grok/${installMatch}/${name}`, rootPath: pluginRoot, scope: "plugin", sourceLabel: displayName, - name: manifest?.name || name, + name: pluginName, displayName, description: manifest?.description || "", - installSources: ["installed-plugins"], - installSource: "installed-plugins", - installMatch: "grok-installed-plugins-dir", + installSources: [installMatch], + installSource: installMatch, + installMatch, installType: "local", - enabled: true, + enabled, evidence: evidence(manifestPath ?? pluginRoot, path.dirname(pluginRoot)), }; plugin.skills = (await collectSkillFiles(pluginRoot, "plugin", displayName, pluginRoot)).sort(sortByName); @@ -158,20 +223,58 @@ async function collectInstalledPlugins(pluginsRoot) { plugin.mcpServers = await collectMcpItems(pluginRoot, "plugin", displayName, pluginRoot); plugins.push(plugin); } - return plugins.sort(sortByName); + return plugins; +} + +async function collectGrokPlugins(grokHome, workspace, configText) { + const section = parseGrokPluginsSectionFromToml(configText ?? ""); + const enabledSet = new Set(section.enabled); + const disabledSet = new Set(section.disabled); + const roots = [ + { root: path.join(grokHome, "plugins"), match: "grok-plugins-dir" }, + { root: path.join(grokHome, "installed-plugins"), match: "grok-installed-plugins-dir" }, + { root: path.join(workspace, ".grok", "plugins"), match: "grok-project-plugins-dir" }, + ]; + for (const extra of section.paths) { + const expanded = path.resolve(expandHome(extra)); + roots.push({ root: expanded, match: "grok-plugins-path-config" }); + } + const byId = new Map(); + const recordFiles = []; + for (const { root, match } of roots) { + if (await pathExists(root)) recordFiles.push(root); + for (const plugin of await collectPluginsFromRoot(root, match, enabledSet, disabledSet)) { + if (!byId.has(plugin.id)) byId.set(plugin.id, plugin); + } + } + return { + plugins: [...byId.values()].sort(sortByName), + recordFiles: [...new Set(recordFiles)], + }; } async function collectGrokUserPrimitives(grokHome) { const configPath = path.join(grokHome, "config.toml"); - const [skills, bundledSkills, commands, hooks, mcps] = await Promise.all([ + const agentsSkillsHome = path.join(os.homedir(), ".agents", "skills"); + const [skills, bundledSkills, agentsSkills, commands, hooks, mcps] = await Promise.all([ collectSkillFiles(path.join(grokHome, "skills"), "user", "User", grokHome), collectSkillFiles(path.join(grokHome, "bundled", "skills"), "user", "User bundled", grokHome), + // Grok also loads ~/.agents/skills (often linked under ~/.grok/skills). + collectSkillFiles(agentsSkillsHome, "user", "User agents", agentsSkillsHome), collectMarkdownItems(path.join(grokHome, "commands"), "command", "user", "User", grokHome), collectHookItems(grokHome, "user", "User", grokHome), collectGrokMcpFromConfig(configPath, "user", "User", grokHome), ]); + // Dedupe skills that appear both under ~/.grok/skills and ~/.agents/skills via symlink. + const byReal = new Map(); + for (const skill of [...skills, ...bundledSkills, ...agentsSkills]) { + const filePath = skill?.filePath ?? skill?.path; + if (!filePath) continue; + const key = await resolveRealPath(filePath); + if (!byReal.has(key)) byReal.set(key, skill); + } return { - skills: [...skills, ...bundledSkills].sort(sortByName), + skills: [...byReal.values()].sort(sortByName), subagents: [], rules: [], commands, @@ -214,21 +317,36 @@ export async function mergeGrokProjectSkills(skills, workspace) { return [...byReal.values()].sort(sortByName); } -async function collectGrokWorkspacePrimitives(workspace) { +async function collectGrokWorkspacePrimitives(workspace, grokHome) { const sourceLabel = await workspaceSourceLabel(workspace); const projectRoot = path.join(workspace, ".grok"); - const project = await collectWorkspaceRootPrimitives(projectRoot, sourceLabel, workspace); - const agentsSkills = await collectSkillFiles( - path.join(workspace, ".agents", "skills"), - "project", - sourceLabel, - workspace, - ); + // When workspace is the user home, workspace/.grok === ~/.grok — do not + // double-count the same tree as both user and project scope. + const projectIsUserHome = (await resolveRealPath(projectRoot)) === (await resolveRealPath(grokHome)); + const project = projectIsUserHome + ? emptyPrimitives() + : await collectWorkspaceRootPrimitives(projectRoot, sourceLabel, workspace); + const agentsProjectRoot = path.join(workspace, ".agents", "skills"); + const agentsUserRoot = path.join(os.homedir(), ".agents", "skills"); + const agentsIsUserHome = (await resolveRealPath(agentsProjectRoot)) + === (await resolveRealPath(agentsUserRoot)); + const agentsSkills = agentsIsUserHome + ? [] + : await collectSkillFiles(agentsProjectRoot, "project", sourceLabel, workspace); + const projectConfigMcps = projectIsUserHome + ? [] + : await collectGrokMcpFromConfig( + path.join(projectRoot, "config.toml"), + "project", + sourceLabel, + workspace, + ); const projectMcps = await collectMcpItems(workspace, "project", sourceLabel, workspace); + // Project hooks under .grok/hooks (collectWorkspaceRootPrimitives may already cover hooks). return { ...project, skills: await mergeGrokProjectSkills([...project.skills, ...agentsSkills], workspace), - mcps: [...project.mcps, ...projectMcps].sort(sortByName), + mcps: [...project.mcps, ...projectConfigMcps, ...projectMcps].sort(sortByName), rules: [ ...project.rules, ...(await collectRuleSources([ @@ -245,11 +363,16 @@ export async function collectGrokCustomizeInventory(options = {}) { )); const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; - const [plugins, user, project] = await Promise.all([ - includeUserHome ? collectInstalledPlugins(path.join(grokHome, "installed-plugins")) : [], + const configPath = path.join(grokHome, "config.toml"); + const configText = includeUserHome ? await readTomlText(configPath) : null; + const [pluginPack, user, project] = await Promise.all([ + collectGrokPlugins(grokHome, workspace, configText), includeUserHome ? collectGrokUserPrimitives(grokHome) : emptyPrimitives(), - collectGrokWorkspacePrimitives(workspace), + collectGrokWorkspacePrimitives(workspace, grokHome), ]); + const plugins = includeUserHome + ? pluginPack.plugins + : pluginPack.plugins.filter((plugin) => plugin.installMatch === "grok-project-plugins-dir"); return { generatedAt: new Date().toISOString(), provider: "grok", @@ -259,13 +382,12 @@ export async function collectGrokCustomizeInventory(options = {}) { plugins, manage: buildManageCollections(plugins, user, project), diagnostics: { - installedPluginState: plugins.length > 0 ? "grok-installed-plugins-dir" : "missing", + installedPluginState: plugins.length > 0 ? "grok-plugins-dirs" : "missing", installedPluginRecordCount: plugins.length, - installedPluginRecordFiles: includeUserHome && (await pathExists(path.join(grokHome, "installed-plugins"))) - ? [path.join(grokHome, "installed-plugins")] - : [], + installedPluginRecordFiles: pluginPack.recordFiles, remotePluginInstallMarkersRequired: false, - configPath: path.join(grokHome, "config.toml"), + configPath, + projectConfigPath: path.join(workspace, ".grok", "config.toml"), }, unsupported: [ "auth.json credentials (never inventoried as values)", diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 4133468..17445be 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -57,7 +57,8 @@ Options: --canvas Explicit legacy canvas.json companion for a split Agent Work Loop bundle --source Reviewed Agent Work Loop report source; projection is performed in memory --mode qoder-canvas, cursor-canvas, markdown, or html (default: qoder-canvas) - --out Output root (default: .qoder/better-harness, or .cursor/better-harness for cursor-canvas) + --out Output root (default: .qoder/better-harness, .cursor/better-harness for cursor-canvas, or ./better-harness for html) + --platform Host id used for default html out root (alias: --provider) --run-dir Run directory: relative values resolve below --out; absolute values remain exact --target Target project path used for run-directory slug --language en or zh-CN (default: input summary.locale, then en) @@ -78,19 +79,20 @@ function parseArgs(argv) { options.validate = true; } else if (arg === "--json") { options.json = true; - } else if (["--findings", "--canvas", "--source", "--mode", "--out", "--run-dir", "--target", "--language", "--sdk-root", "--sdk-declarations"].includes(arg)) { + } else if (["--findings", "--canvas", "--source", "--mode", "--out", "--run-dir", "--target", "--language", "--sdk-root", "--sdk-declarations", "--platform", "--provider"].includes(arg)) { options[arg.slice(2)] = argv[++index]; - } else if (arg.startsWith("--findings=") || arg.startsWith("--canvas=") || arg.startsWith("--source=") || arg.startsWith("--mode=") || arg.startsWith("--out=") || arg.startsWith("--run-dir=") || arg.startsWith("--target=") || arg.startsWith("--language=") || arg.startsWith("--sdk-root=") || arg.startsWith("--sdk-declarations=")) { + } else if (arg.startsWith("--findings=") || arg.startsWith("--canvas=") || arg.startsWith("--source=") || arg.startsWith("--mode=") || arg.startsWith("--out=") || arg.startsWith("--run-dir=") || arg.startsWith("--target=") || arg.startsWith("--language=") || arg.startsWith("--sdk-root=") || arg.startsWith("--sdk-declarations=") || arg.startsWith("--platform=") || arg.startsWith("--provider=")) { const [name, value] = arg.slice(2).split(/=(.*)/s); options[name] = value; } else { throw Object.assign(new Error(`Unknown argument: ${arg}`), { code: "UNKNOWN_ARGUMENT" }); } } + const hostId = String(options.platform ?? options.provider ?? "").toLowerCase(); options.out ??= options.mode === "cursor-canvas" ? ".cursor/better-harness" - : options.mode === "html" && String(options.platform ?? options.provider ?? "").toLowerCase() === "grok" - ? ".grok/better-harness" + : options.mode === "html" && hostId && hostId !== "qoder" + ? `.${hostId}/better-harness` : ".qoder/better-harness"; return options; } diff --git a/scripts/session-analysis/fs.mjs b/scripts/session-analysis/fs.mjs index b70f944..55d9c02 100644 --- a/scripts/session-analysis/fs.mjs +++ b/scripts/session-analysis/fs.mjs @@ -1,5 +1,5 @@ import { createReadStream } from "node:fs"; -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; @@ -61,11 +61,24 @@ export async function walkFiles(root, options = {}) { const maxDepth = options.maxDepth ?? Infinity; const limit = options.limit ?? Infinity; const files = []; + const visitedDirs = new Set(); + + async function resolveDirIdentity(dir) { + try { + return await realpath(dir); + } catch { + return path.resolve(dir); + } + } async function visit(dir, depth) { if (files.length >= limit || depth > maxDepth) { return; } + const realDir = await resolveDirIdentity(dir); + if (visitedDirs.has(realDir)) return; + visitedDirs.add(realDir); + let entries; try { entries = await readdir(dir, { withFileTypes: true }); @@ -81,6 +94,7 @@ export async function walkFiles(root, options = {}) { const fullPath = path.join(dir, entry.name); // Dirent isDirectory/isFile are false for symlinks; follow them so hosts // that install skills via ln -s (e.g. Grok ~/.grok/skills) are inventoried. + // Track realpath identities so symlink cycles cannot re-enter a directory. const linkedDirectory = entry.isSymbolicLink() ? await isDirectory(fullPath) : false; if (entry.isDirectory() || linkedDirectory) { await visit(fullPath, depth + 1); diff --git a/scripts/session-analysis/platforms/grok.mjs b/scripts/session-analysis/platforms/grok.mjs index 87e14ed..ca96c13 100644 --- a/scripts/session-analysis/platforms/grok.mjs +++ b/scripts/session-analysis/platforms/grok.mjs @@ -62,19 +62,55 @@ function finiteNumber(...values) { return values.find((value) => typeof value === "number" && Number.isFinite(value)); } +/** + * signals.json holds session counters and context-window occupancy, not + * per-turn accounting. Never treat contextTokensUsed as totalTokens. + * Prefer turn_completed.usage from updates.jsonl for model usage. + */ function normalizeUsageFromSignals(signals) { if (!signals || typeof signals !== "object") return null; const observed = {}; for (const [key, value] of [ ["inputTokens", finiteNumber(signals.inputTokens, signals.input_tokens, signals.promptTokens)], ["outputTokens", finiteNumber(signals.outputTokens, signals.output_tokens, signals.completionTokens)], - ["totalTokens", finiteNumber(signals.totalTokens, signals.total_tokens, signals.contextTokensUsed)], + // Explicit totals only — contextTokensUsed is window occupancy, not spend. + ["totalTokens", finiteNumber(signals.totalTokens, signals.total_tokens)], + ]) { + if (value !== undefined) observed[key] = value; + } + return Object.keys(observed).length > 0 ? observed : null; +} + +function normalizeUsageFromTurn(usage) { + if (!usage || typeof usage !== "object") return null; + const observed = {}; + for (const [key, value] of [ + ["inputTokens", finiteNumber(usage.inputTokens, usage.input_tokens, usage.promptTokens)], + ["outputTokens", finiteNumber(usage.outputTokens, usage.output_tokens, usage.completionTokens)], + ["totalTokens", finiteNumber(usage.totalTokens, usage.total_tokens)], + ["cacheReadInputTokens", finiteNumber(usage.cachedReadTokens, usage.cacheReadInputTokens, usage.cache_read_input_tokens)], ]) { if (value !== undefined) observed[key] = value; } return Object.keys(observed).length > 0 ? observed : null; } +function toolStatusOf(raw, update) { + const candidates = [ + update?.status, + update?.state, + raw?.params?._meta?.updateParams?.status, + update?._meta?.status, + ]; + for (const value of candidates) { + if (value == null || value === "") continue; + return String(value).toLowerCase(); + } + return ""; +} + +const TERMINAL_TOOL_STATUSES = new Set(["completed", "failed", "error", "cancelled", "canceled"]); + function inferTimestamp(raw) { const ts = raw?.timestamp ?? raw?.params?.update?._meta?.agentTimestampMs ?? null; if (typeof ts === "number" && ts > 1e12) { @@ -101,8 +137,9 @@ function updatesRecordToEvents(raw, sourceRef, options = {}) { const update = raw?.params?.update ?? raw?.update ?? null; const sessionUpdate = update?.sessionUpdate ?? update?.type ?? null; const events = []; + const isSessionUpdate = method === "session/update" || method === "_x.ai/session/update"; - if (method === "session/update" && sessionUpdate) { + if (isSessionUpdate && sessionUpdate) { if (sessionUpdate === "user_message_chunk" || sessionUpdate === "user_message") { const text = update?.content?.text ?? update?.text ?? ""; const content = typeof text === "string" ? text : ""; @@ -135,41 +172,87 @@ function updatesRecordToEvents(raw, sourceRef, options = {}) { }); return events; } - if (sessionUpdate === "tool_call" || sessionUpdate === "tool_call_update") { - const toolName = update?.toolName ?? update?.name ?? update?.title ?? "unknown-tool"; - const status = String(update?.status ?? update?.state ?? "").toLowerCase(); - const isResult = sessionUpdate === "tool_call_update" - || ["completed", "failed", "error", "cancelled", "canceled"].includes(status); - if (isResult) { - const success = !["failed", "error", "cancelled", "canceled"].includes(status); - const event = { + if (sessionUpdate === "tool_call") { + const toolName = update?.toolName + ?? update?.name + ?? update?._meta?.["x.ai/tool"]?.name + ?? update?.title + ?? "unknown-tool"; + events.push({ + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName, + toolInvocationId: update?.toolCallId ?? update?.id ?? null, + evidenceRef: evidenceRef(sourceRef, "tool.call"), + summary: `${toolName} request`, + }); + return events; + } + if (sessionUpdate === "tool_call_update") { + const toolName = update?.toolName + ?? update?.name + ?? update?._meta?.["x.ai/tool"]?.name + ?? update?.title + ?? "unknown-tool"; + const status = toolStatusOf(raw, update); + // Progress / status-less updates are not terminal results (Grok 0.2.x). + if (!TERMINAL_TOOL_STATUSES.has(status)) { + events.push({ ...base, - type: "tool.result", - category: "tool", - lifecyclePhase: "result", + type: "metadata.tool_call_update", + category: "metadata", toolName, toolInvocationId: update?.toolCallId ?? update?.id ?? null, - success, - hasError: !success, - evidenceRef: evidenceRef(sourceRef, "tool.result"), - summary: success ? "tool result" : `tool result ${status || "failed"}`, - }; - const rawResult = update?.content ?? update?.result ?? update?.output ?? null; - if (rawResult != null) { - const facts = parseResultFacts(String(typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult)).slice(-8_192)); - if (facts) event.resultFacts = facts; - } - events.push(event); + evidenceRef: evidenceRef(sourceRef, "metadata.tool_call_update"), + summary: status ? `tool update ${status}` : "tool update progress", + }); + return events; + } + const success = !["failed", "error", "cancelled", "canceled"].includes(status); + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolName, + toolInvocationId: update?.toolCallId ?? update?.id ?? null, + success, + hasError: !success, + evidenceRef: evidenceRef(sourceRef, "tool.result"), + summary: success ? "tool result" : `tool result ${status}`, + }; + const rawResult = update?.content ?? update?.result ?? update?.output ?? null; + if (rawResult != null) { + const facts = parseResultFacts(String(typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult)).slice(-8_192)); + if (facts) event.resultFacts = facts; + } + events.push(event); + return events; + } + if (sessionUpdate === "turn_completed") { + const usage = normalizeUsageFromTurn(update?.usage); + if (usage) { + events.push({ + ...base, + type: "model.response.completed", + category: "model", + modelUsage: usage, + usageFieldsObserved: true, + evidenceRef: evidenceRef(sourceRef, "model.response.completed"), + summary: "Grok turn_completed usage", + model: update?.usage?.modelUsage + ? Object.keys(update.usage.modelUsage)[0] ?? null + : null, + }); } else { events.push({ ...base, - type: "tool.call", - category: "tool", - lifecyclePhase: "request", - toolName, - toolInvocationId: update?.toolCallId ?? update?.id ?? null, - evidenceRef: evidenceRef(sourceRef, "tool.call"), - summary: `${toolName} request`, + type: "metadata.turn_completed", + category: "metadata", + evidenceRef: evidenceRef(sourceRef, "metadata.turn_completed"), + summary: update?.stop_reason || "turn_completed", }); } return events; @@ -290,6 +373,41 @@ async function listEncodedSessionGroups(sessionsRoot, encodedNames) { .map((entry) => path.join(sessionsRoot, entry.name)); } +/** + * Match session groups by exact encodeURIComponent(cwd) name, or by the + * long-path form: slug+hash group with a `.cwd` file holding the original path + * when encodeURIComponent(cwd) exceeds 255 bytes (Grok 0.2.x docs). + */ +async function listMatchingSessionGroups(sessionsRoot, scope) { + const encodedWanted = new Set(scope._encodedGroupNames ?? []); + let entries; + try { + entries = await readdir(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + const groups = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const groupPath = path.join(sessionsRoot, entry.name); + if (encodedWanted.has(entry.name)) { + groups.push(groupPath); + continue; + } + const cwdMarker = path.join(groupPath, ".cwd"); + if (!(await pathExists(cwdMarker))) continue; + try { + const recorded = (await readFile(cwdMarker, "utf8")).trim(); + if (recorded && isScopedWorkspaceMatch(recorded, scope)) { + groups.push(groupPath); + } + } catch { + // ignore unreadable markers + } + } + return groups; +} + export class GrokSessionAnalyzer extends SessionAnalyzer { currentSessionId() { return process.env.GROK_SESSION_ID ?? null; @@ -325,7 +443,7 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { } async discoverSourceRoots(scope) { - const matching = await listEncodedSessionGroups(scope.sessionsDir, scope._encodedGroupNames); + const matching = await listMatchingSessionGroups(scope.sessionsDir, scope); return [{ id: "grok-sessions", kind: "grok-session-dir", @@ -344,13 +462,22 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { const sessions = new Map(); const root = roots.find((item) => item.kind === "grok-session-dir"); if (!root) return []; - const groups = await listEncodedSessionGroups(scope.sessionsDir, scope._encodedGroupNames); + const groups = await listMatchingSessionGroups(scope.sessionsDir, scope); const seen = new Set(); for (const groupPath of groups) { let realGroup; try { realGroup = realpathSync.native(groupPath); } catch { realGroup = path.resolve(groupPath); } if (seen.has(realGroup)) continue; seen.add(realGroup); + let groupCwdFromMarker = null; + try { + const marker = path.join(groupPath, ".cwd"); + if (await pathExists(marker)) { + groupCwdFromMarker = (await readFile(marker, "utf8")).trim() || null; + } + } catch { + groupCwdFromMarker = null; + } let sessionDirs; try { sessionDirs = await readdir(groupPath, { withFileTypes: true }); @@ -367,8 +494,8 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { ?? summary?.cwd ?? (typeof summary?.info === "object" ? summary.info.cwd : null) ?? null; - // Prefer summary cwd; fall back to decoding the group directory name. - let qualifiedCwd = cwd; + // Prefer summary cwd; then .cwd marker (long-path groups); then decode group name. + let qualifiedCwd = cwd ?? groupCwdFromMarker; if (!qualifiedCwd) { try { qualifiedCwd = decodeURIComponent(path.basename(groupPath)); @@ -384,7 +511,8 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { const updatesPath = path.join(sessionPath, "updates.jsonl"); const historyPath = path.join(sessionPath, "chat_history.jsonl"); const signalsPath = path.join(sessionPath, "signals.json"); - if (await pathExists(updatesPath)) { + const hasUpdates = await pathExists(updatesPath); + if (hasUpdates) { addRef(sessions, sessionId, scope.workspace, { kind: "grok-updates-jsonl", role: "session-transcript", @@ -393,11 +521,11 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { lastSeen, cwd: qualifiedCwd, }); - } - if (await pathExists(historyPath)) { + } else if (await pathExists(historyPath)) { + // updates.jsonl is authoritative; only fall back to chat_history when missing. addRef(sessions, sessionId, scope.workspace, { kind: "grok-chat-history-jsonl", - role: "session-transcript-secondary", + role: "session-transcript", path: historyPath, firstSeen, lastSeen, @@ -407,7 +535,7 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { if (await pathExists(signalsPath)) { addRef(sessions, sessionId, scope.workspace, { kind: "grok-signals-json", - role: "session-usage", + role: "session-metadata", path: signalsPath, firstSeen, lastSeen, @@ -462,6 +590,9 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { } if (ref.kind === "grok-signals-json") { const signals = await readJsonSafe(ref.path); + // Prefer turn_completed usage from updates.jsonl. signals.json is + // counters/context occupancy; only emit usage when explicit token + // accounting fields exist (never contextTokensUsed as total). const usage = normalizeUsageFromSignals(signals); if (usage && !usageEmitted) { usageEmitted = true; @@ -478,8 +609,19 @@ export class GrokSessionAnalyzer extends SessionAnalyzer { evidenceRef: evidenceRef({ ...ref, sessionId: session.sessionId }, "model.response.completed"), summary: "Grok session signals usage", }); + } else if (signals && typeof signals.contextTokensUsed === "number") { + events.push({ + sessionId: session.sessionId, + timestamp: normalizeTimestamp(session.lastSeen), + sourceKind: ref.kind, + planningScope: "workspace", + cwd: identityCwd ?? ref.cwd ?? null, + type: "metadata.context_window", + category: "metadata", + evidenceRef: evidenceRef({ ...ref, sessionId: session.sessionId }, "metadata.context_window"), + summary: `context tokens used ${signals.contextTokensUsed}`, + }); } - // Missing or empty signals → leave usage unobserved (do not invent zeros). continue; } if (ref.kind === "grok-summary-json") { diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index ca3dbaf..f259e9d 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -2396,7 +2396,8 @@ async function makeGrokFixture() { }, }); - const pluginRoot = path.join(grokHome, "installed-plugins", "sample-plugin"); + // Prefer native ~/.grok/plugins discovery path (also still accept installed-plugins). + const pluginRoot = path.join(grokHome, "plugins", "sample-plugin"); await writeJson(path.join(pluginRoot, "plugin.json"), { name: "sample-plugin", displayName: "Sample Plugin", @@ -2411,6 +2412,14 @@ async function makeGrokFixture() { path.join(workspace, ".grok", "skills", "project-flow", "SKILL.md"), "---\nname: project-flow\ndescription: Project Grok workflow.\n---\n", ); + await writeText( + path.join(workspace, ".grok", "config.toml"), + [ + '[mcp_servers.project_docs]', + 'command = "npx"', + "", + ].join("\n"), + ); await writeText( path.join(workspace, ".agents", "skills", "shared-standard", "SKILL.md"), "---\nname: shared-standard\ndescription: Shared Agent Skills standard workflow.\n---\n", @@ -2433,8 +2442,9 @@ test("collectAgentCustomizeInventory returns Grok skills, MCP, hooks, and instal assert.equal(inventory.grokHome, fixture.grokHome); assert.equal(inventory.plugins.length, 1); assert.equal(inventory.plugins[0].name, "sample-plugin"); - assert.equal(inventory.plugins[0].installMatch, "grok-installed-plugins-dir"); + assert.equal(inventory.plugins[0].installMatch, "grok-plugins-dir"); assert.deepEqual(inventory.plugins[0].skills.map((skill) => skill.name), ["sample-skill"]); + assert.equal(inventory.diagnostics.installedPluginState, "grok-plugins-dirs"); assert.ok( filterManageItems(inventory, { tab: "skills", scopeKind: "user" }) @@ -2452,6 +2462,10 @@ test("collectAgentCustomizeInventory returns Grok skills, MCP, hooks, and instal filterManageItems(inventory, { tab: "mcps", scopeKind: "user" }) .some((item) => item.name === "docs" && item.enabled !== false), ); + assert.ok( + filterManageItems(inventory, { tab: "mcps", scopeKind: "project" }) + .some((item) => item.name === "project_docs"), + ); const disabled = filterManageItems(inventory, { tab: "mcps", scopeKind: "user" }) .find((item) => item.name === "disabled_search"); assert.ok(disabled); diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index 88840c7..2eb7a32 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -1298,7 +1298,7 @@ test("WorkBuddy source roots stay absent without workspace-matching project dire assert.equal(result.sessions.length, 0); }); -test("Grok provider expands user/assistant/tool events and optional signals usage", async () => { +test("Grok provider expands user/assistant/tool events and turn_completed usage", async () => { const root = await fixtureRoot("session-grok-provider-"); const home = path.join(root, ".grok"); const workspace = path.join(root, "workspace", "project"); @@ -1335,6 +1335,30 @@ test("Grok provider expands user/assistant/tool events and optional signals usag }, }, }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call_update", + toolName: "run_terminal_command", + toolCallId: "call_1", + status: null, + _meta: { isoTimestamp: "2026-08-01T10:00:02.500Z" }, + }, + }, + }, + { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call_update", + toolName: "run_terminal_command", + toolCallId: "call_1", + status: "in_progress", + _meta: { isoTimestamp: "2026-08-01T10:00:02.750Z" }, + }, + }, + }, { method: "session/update", params: { @@ -1368,12 +1392,32 @@ test("Grok provider expands user/assistant/tool events and optional signals usag }, }, }, + { + method: "_x.ai/session/update", + params: { + update: { + sessionUpdate: "turn_completed", + stop_reason: "end_turn", + usage: { + inputTokens: 120, + outputTokens: 40, + totalTokens: 160, + cachedReadTokens: 10, + }, + }, + }, + }, ]); + // contextTokensUsed must not be treated as total token spend await writeFile(path.join(sessionDir, "signals.json"), JSON.stringify({ - inputTokens: 120, - outputTokens: 40, - totalTokens: 160, + contextTokensUsed: 44532, + contextWindowTokens: 500000, }, null, 2)); + // chat_history must not double-count when updates.jsonl exists + await writeJsonl(path.join(sessionDir, "chat_history.jsonl"), [ + { role: "user", content: "duplicate user", timestamp: "2026-08-01T10:00:01.000Z" }, + { role: "assistant", content: "duplicate assistant", timestamp: "2026-08-01T10:00:04.000Z" }, + ]); const analyzer = new GrokSessionAnalyzer(); const discovery = await analyzer.analyze({ command: "sources", workspace, home }); @@ -1389,17 +1433,55 @@ test("Grok provider expands user/assistant/tool events and optional signals usag assert.equal(events.find((event) => event.type === "user")?.userText, "Implement the Grok provider"); assert.equal(events.filter((event) => event.type === "tool.call").length, 1); assert.equal(events.filter((event) => event.type === "tool.result").length, 1); + assert.equal(events.filter((event) => event.type === "metadata.tool_call_update").length, 2); assert.equal(events.filter((event) => event.type === "assistant").length, 1); assert.ok(events.some((event) => event.type === "metadata.thought_chunk")); const usage = events.find((event) => event.type === "model.response.completed"); assert.equal(usage?.modelUsage?.inputTokens, 120); assert.equal(usage?.modelUsage?.outputTokens, 40); + assert.equal(usage?.modelUsage?.totalTokens, 160); + assert.ok(!events.some((event) => event.modelUsage?.totalTokens === 44532)); + assert.ok(events.some((event) => event.type === "metadata.context_window")); const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); assert.equal(facts.kind, "session-core-facts"); assert.equal(facts.scope.platform, "grok"); assert.doesNotMatch(JSON.stringify(facts), new RegExp(sessionId, "u")); }); +test("Grok provider discovers long-path session groups via .cwd marker", async () => { + const root = await fixtureRoot("session-grok-long-path-"); + const home = path.join(root, ".grok"); + const workspace = path.join(root, "workspace", "deeply", "nested", "project"); + const sessionId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + // Simulate Grok long-path form: slug+hash group with .cwd (not encodeURIComponent name). + const group = "workspace-deeply-nested-project-a1b2c3d4"; + const groupPath = path.join(home, "sessions", group); + const sessionDir = path.join(groupPath, sessionId); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(groupPath, ".cwd"), `${workspace}\n`); + await writeFile(path.join(sessionDir, "summary.json"), JSON.stringify({ + info: { id: sessionId, cwd: workspace }, + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:05:00.000Z", + }, null, 2)); + await writeJsonl(path.join(sessionDir, "updates.jsonl"), [ + { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { text: "long path" }, + _meta: { isoTimestamp: "2026-08-01T10:00:01.000Z" }, + }, + }, + }, + ]); + const result = await new GrokSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0].sessionId, sessionId); + assert.equal(result.sources[0].exists, true); +}); + test("Grok provider excludes foreign workspace session groups", async () => { const root = await fixtureRoot("session-grok-isolation-"); const home = path.join(root, ".grok"); From a2fe6b2b7bf0cc4d4856868e2c9bd4b991a2306d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Mon, 3 Aug 2026 11:39:16 +0800 Subject: [PATCH 5/6] fix(grok): close PR review blockers on inventory, usage, and spec Rewrite the corrupted host-adapter spec, dedupe plugins by realpath while merging installSources, aggregate nested turn_completed.usage.modelUsage, validate harness render --platform against supported hosts, and keep includeUserHome=false diagnostics free of user-home plugin paths. Co-authored-by: Grok (Grok 4.5) --- docs/specs/2026-08-02-grok-host-adapter.md | 46 +++++++------ scripts/agent-customize/providers/grok.mjs | 74 +++++++++++++++------ scripts/harness-analysis/render-report.mjs | 9 +++ scripts/session-analysis/platforms/grok.mjs | 48 ++++++++++++- test/agent-customize.test.mjs | 41 ++++++++++++ test/session-analysis-providers.test.mjs | 33 +++++++-- 6 files changed, 203 insertions(+), 48 deletions(-) diff --git a/docs/specs/2026-08-02-grok-host-adapter.md b/docs/specs/2026-08-02-grok-host-adapter.md index d0e2336..3e31aca 100644 --- a/docs/specs/2026-08-02-grok-host-adapter.md +++ b/docs/specs/2026-08-02-grok-host-adapter.md @@ -22,52 +22,54 @@ | Item | Value | | --- | --- | | Home | `GROK_HOME` env, else `~/.grok` | -| Config | `$GROK_HOME/config.toml` | -| Skills | `$GROK_HOME/skills/`, `$GROK_HOME/bundled/skills/`, `/.grok/skills/`, `/.agents/skills/` | +| Config | `$GROK_HOME/config.toml`; project may also use `/.grok/config.toml` | +| Skills | `$GROK_HOME/skills/`, `$GROK_HOME/bundled/skills/`, `~/.agents/skills/`, `/.grok/skills/`, `/.agents/skills/` | | Hooks | `$GROK_HOME/hooks/*.json`, project `.grok/hooks` when present | -| MCP | `[mcp_servers.]` tables in `config.toml` (enabled flag) | -| Plugins | `$GROK_HOME/installed-plugins/`, `marketplace-cache/` (inventory only) | -| Sessions | `$GROK_HOME/sessions///` with `summary.json`, `updates.jsonl`, `chat_history.jsonl`, `signals.json` | +| MCP | `[mcp_servers.]` tables in user and project `config.toml` (enabled flag) | +| Plugins | Trusted user `$GROK_HOME/plugins/`, legacy `$GROK_HOME/installed-plugins/`, project `/.grok/plugins/`, plus `[plugins].paths` from config; physical roots are deduped by realpath | +| Sessions | `$GROK_HOME/sessions///` with `summary.json`, `updates.jsonl`, optional `chat_history.jsonl`, `signals.json` | +| Long cwd groups | When `encodeURIComponent(cwd)` exceeds 255 bytes, Grok uses a slug+hash group directory and stores the original path in `.cwd` | | Report root | `/.grok/better-harness/` | | Output mode | `html` | ### Workspace qualification -' - ) -# add notes section if missing -if 'updates.jsonl is authoritative' not in t: - t = t.replace( - '### Privacy -', - - `updates.jsonl` is the authoritative conversation log; `chat_history.jsonl` is only used when updates are missing (never both). -- Terminal tool results require an explicit terminal status (`completed`/`failed`/…); progress and status-less `tool_call_update` stay metadata. -- Model usage comes from `turn_completed.usage` on `_x.ai/session/update` records. `signals.contextTokensUsed` is context occupancy, not total spend. -- When `encodeURIComponent(cwd)` exceeds 255 bytes, Grok uses a slug+hash group directory and stores the original path in `.cwd`; discovery matches both forms. - -### Privacy - - Prefer `summary.json` → `info.cwd` (or equivalent) matched via existing workspace-match helpers. -- Session group directory name is `encodeURIComponent(absoluteCwd)` (e.g. `/Users/work` → `%2FUsers%2Fwork`). +- Session group directory name is normally `encodeURIComponent(absoluteCwd)` (e.g. `/Users/work` → `%2FUsers%2Fwork`). +- Long-path groups are also matched when `.cwd` records a workspace-qualified path. - Foreign-workspace sessions never enter facts for a report. +### Conversation evidence + +- `updates.jsonl` is the authoritative conversation log; `chat_history.jsonl` is only used when updates are missing (never both). +- Terminal tool results require an explicit terminal status (`completed` / `failed` / `error` / `cancelled` / `canceled`); progress and status-less `tool_call_update` stay metadata. +- Model usage comes from `turn_completed.usage` on `_x.ai/session/update` records, including nested `usage.modelUsage.` when flat totals are absent. +- `signals.contextTokensUsed` is context-window occupancy, not total spend; it is never mapped to `totalTokens`. + ### Privacy - Never serialize `auth.json`, API keys, or MCP `env` secret values. - Inventory may record server names, enabled flags, and path existence only. +### Stated approximations + +- Plugin `enabled` follows `[plugins].enabled` / `[plugins].disabled` when present. When neither list is declared, discovered plugin directories are treated as enabled for inventory (filesystem presence), which is a documented approximation of Grok's runtime enablement/trust model rather than a full parity claim. +- Marketplace catalog entries under `marketplace-cache/` are not treated as installed plugins without an install root. + ## Acceptance ids | Id | Criterion | | --- | --- | | Grok-A1 | Provider inventory returns skills/hooks/mcp/plugins scopes for synthetic home + workspace | | Grok-A2 | `GROK_HOME` / `--grok-home` overrides default without foreign home fallback | +| Grok-A3 | One physical plugin root discovered via multiple path aliases counts once (realpath dedupe) | | Grok-S1 | Session sources list only cwd-matching sessions under encoded group dir | | Grok-S2 | Foreign session group excluded from sources | -| Grok-S3 | Missing `signals.json` usage stays unobserved (not zero-filled) | +| Grok-S3 | Missing `signals.json` usage stays unobserved (not zero-filled); `contextTokensUsed` alone does not invent totals | | Grok-S4 | Unknown `updates.jsonl` events preserved as metadata | +| Grok-S5 | Nested `turn_completed.usage.modelUsage` contributes observed model usage | | Grok-R1 | `platform=grok` accepted by evidence-bundle and session-analysis CLI help | -| Grok-R2 | HTML render default out root documents `.grok/better-harness` | +| Grok-R2 | HTML render default out root documents `.grok/better-harness`; unsupported `--platform` values fail closed | ## Smoke (local) diff --git a/scripts/agent-customize/providers/grok.mjs b/scripts/agent-customize/providers/grok.mjs index 64adb42..462f43d 100644 --- a/scripts/agent-customize/providers/grok.mjs +++ b/scripts/agent-customize/providers/grok.mjs @@ -226,29 +226,61 @@ async function collectPluginsFromRoot(pluginsRoot, installMatch, enabledSet, dis return plugins; } -async function collectGrokPlugins(grokHome, workspace, configText) { +async function collectGrokPlugins(grokHome, workspace, configText, { includeUserHome = true } = {}) { const section = parseGrokPluginsSectionFromToml(configText ?? ""); const enabledSet = new Set(section.enabled); const disabledSet = new Set(section.disabled); - const roots = [ - { root: path.join(grokHome, "plugins"), match: "grok-plugins-dir" }, - { root: path.join(grokHome, "installed-plugins"), match: "grok-installed-plugins-dir" }, - { root: path.join(workspace, ".grok", "plugins"), match: "grok-project-plugins-dir" }, - ]; - for (const extra of section.paths) { - const expanded = path.resolve(expandHome(extra)); - roots.push({ root: expanded, match: "grok-plugins-path-config" }); + const grokHomeReal = await resolveRealPath(grokHome); + const projectPluginsRoot = path.join(workspace, ".grok", "plugins"); + const projectIsUserHome = (await resolveRealPath(path.join(workspace, ".grok"))) === grokHomeReal; + + const roots = []; + if (includeUserHome) { + roots.push( + { root: path.join(grokHome, "plugins"), match: "grok-plugins-dir" }, + { root: path.join(grokHome, "installed-plugins"), match: "grok-installed-plugins-dir" }, + ); + for (const extra of section.paths) { + roots.push({ root: path.resolve(expandHome(extra)), match: "grok-plugins-path-config" }); + } + } + // Project plugins only when they are not the same physical tree as the user home. + if (!projectIsUserHome) { + roots.push({ root: projectPluginsRoot, match: "grok-project-plugins-dir" }); } - const byId = new Map(); + + // Dedupe physical plugin directories; merge discovery roots into installSources. + const byRealRoot = new Map(); const recordFiles = []; + const seenRecordRoots = new Set(); for (const { root, match } of roots) { - if (await pathExists(root)) recordFiles.push(root); + if (!(await pathExists(root))) continue; + const realScanRoot = await resolveRealPath(root); + if (!seenRecordRoots.has(realScanRoot)) { + seenRecordRoots.add(realScanRoot); + recordFiles.push(root); + } for (const plugin of await collectPluginsFromRoot(root, match, enabledSet, disabledSet)) { - if (!byId.has(plugin.id)) byId.set(plugin.id, plugin); + const realPluginRoot = await resolveRealPath(plugin.rootPath); + const existing = byRealRoot.get(realPluginRoot); + if (!existing) { + byRealRoot.set(realPluginRoot, { + ...plugin, + id: `grok/plugin/${path.basename(realPluginRoot)}`, + installSources: [match], + installSource: match, + installMatch: match, + }); + continue; + } + const sources = new Set([...(existing.installSources ?? [existing.installMatch]), match]); + existing.installSources = [...sources].sort(); + // Prefer the first discovered match as primary installMatch; require enabled in all sightings. + existing.enabled = existing.enabled && plugin.enabled; } } return { - plugins: [...byId.values()].sort(sortByName), + plugins: [...byRealRoot.values()].sort(sortByName), recordFiles: [...new Set(recordFiles)], }; } @@ -364,15 +396,17 @@ export async function collectGrokCustomizeInventory(options = {}) { const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const configPath = path.join(grokHome, "config.toml"); - const configText = includeUserHome ? await readTomlText(configPath) : null; + // Project config may declare plugins/MCP independently of the user home. + const userConfigText = includeUserHome ? await readTomlText(configPath) : null; + const projectConfigText = await readTomlText(path.join(workspace, ".grok", "config.toml")); + // Prefer user-home [plugins] tables for enable lists; fall back to project config. + const pluginConfigText = userConfigText ?? projectConfigText; const [pluginPack, user, project] = await Promise.all([ - collectGrokPlugins(grokHome, workspace, configText), + collectGrokPlugins(grokHome, workspace, pluginConfigText, { includeUserHome }), includeUserHome ? collectGrokUserPrimitives(grokHome) : emptyPrimitives(), collectGrokWorkspacePrimitives(workspace, grokHome), ]); - const plugins = includeUserHome - ? pluginPack.plugins - : pluginPack.plugins.filter((plugin) => plugin.installMatch === "grok-project-plugins-dir"); + const plugins = pluginPack.plugins; return { generatedAt: new Date().toISOString(), provider: "grok", @@ -384,9 +418,10 @@ export async function collectGrokCustomizeInventory(options = {}) { diagnostics: { installedPluginState: plugins.length > 0 ? "grok-plugins-dirs" : "missing", installedPluginRecordCount: plugins.length, + // recordFiles already scoped by includeUserHome inside collectGrokPlugins. installedPluginRecordFiles: pluginPack.recordFiles, remotePluginInstallMarkersRequired: false, - configPath, + configPath: includeUserHome ? configPath : path.join(workspace, ".grok", "config.toml"), projectConfigPath: path.join(workspace, ".grok", "config.toml"), }, unsupported: [ @@ -394,6 +429,7 @@ export async function collectGrokCustomizeInventory(options = {}) { "MCP env secret values from config.toml", "marketplace-cache catalog entries without install", "runtime plugin trust state beyond enabled inventory", + "plugin enablement/trust precedence is filesystem-presence approximation when no enabled/disabled lists are declared", ], }; } diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index 17445be..c8f2cb7 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -89,6 +89,15 @@ function parseArgs(argv) { } } const hostId = String(options.platform ?? options.provider ?? "").toLowerCase(); + const supportedHtmlHosts = new Set([ + "qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok", + ]); + if (hostId && !supportedHtmlHosts.has(hostId)) { + throw Object.assign( + new Error(`unsupported render platform: ${hostId}. Supported platforms: ${[...supportedHtmlHosts].join(", ")}.`), + { code: "UNSUPPORTED_RENDER_PLATFORM" }, + ); + } options.out ??= options.mode === "cursor-canvas" ? ".cursor/better-harness" : options.mode === "html" && hostId && hostId !== "qoder" diff --git a/scripts/session-analysis/platforms/grok.mjs b/scripts/session-analysis/platforms/grok.mjs index ca96c13..16ada23 100644 --- a/scripts/session-analysis/platforms/grok.mjs +++ b/scripts/session-analysis/platforms/grok.mjs @@ -81,6 +81,15 @@ function normalizeUsageFromSignals(signals) { return Object.keys(observed).length > 0 ? observed : null; } +function addUsageField(observed, key, value) { + if (value === undefined) return; + observed[key] = (observed[key] ?? 0) + value; +} + +/** + * Accept flat turn usage and/or nested usage.modelUsage. objects. + * Nested modelUsage values are summed across models for the turn total. + */ function normalizeUsageFromTurn(usage) { if (!usage || typeof usage !== "object") return null; const observed = {}; @@ -92,9 +101,44 @@ function normalizeUsageFromTurn(usage) { ]) { if (value !== undefined) observed[key] = value; } + + const modelUsage = usage.modelUsage && typeof usage.modelUsage === "object" ? usage.modelUsage : null; + if (modelUsage && Object.keys(observed).length === 0) { + for (const perModel of Object.values(modelUsage)) { + if (!perModel || typeof perModel !== "object") continue; + addUsageField(observed, "inputTokens", finiteNumber(perModel.inputTokens, perModel.input_tokens)); + addUsageField(observed, "outputTokens", finiteNumber(perModel.outputTokens, perModel.output_tokens)); + addUsageField(observed, "totalTokens", finiteNumber(perModel.totalTokens, perModel.total_tokens)); + addUsageField( + observed, + "cacheReadInputTokens", + finiteNumber(perModel.cachedReadTokens, perModel.cacheReadInputTokens, perModel.cache_read_input_tokens), + ); + } + } else if (modelUsage && observed.totalTokens === undefined) { + // Flat partials present but total missing: fill from nested when available. + let nestedTotal = 0; + let saw = false; + for (const perModel of Object.values(modelUsage)) { + const value = finiteNumber(perModel?.totalTokens, perModel?.total_tokens); + if (value !== undefined) { + nestedTotal += value; + saw = true; + } + } + if (saw) observed.totalTokens = nestedTotal; + } + return Object.keys(observed).length > 0 ? observed : null; } +function primaryModelFromUsage(usage) { + const modelUsage = usage?.modelUsage; + if (!modelUsage || typeof modelUsage !== "object") return null; + const names = Object.keys(modelUsage); + return names.length > 0 ? names[0] : null; +} + function toolStatusOf(raw, update) { const candidates = [ update?.status, @@ -242,9 +286,7 @@ function updatesRecordToEvents(raw, sourceRef, options = {}) { usageFieldsObserved: true, evidenceRef: evidenceRef(sourceRef, "model.response.completed"), summary: "Grok turn_completed usage", - model: update?.usage?.modelUsage - ? Object.keys(update.usage.modelUsage)[0] ?? null - : null, + model: primaryModelFromUsage(update?.usage), }); } else { events.push({ diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index f259e9d..2394eda 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -2506,6 +2506,47 @@ test("agent-customize CLI honours --grok-home instead of the real user home", as } }); +test("Grok plugin inventory dedupes one physical plugin reached through multiple roots", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-grok-plugin-dedupe-")); + const grokHome = path.join(root, ".grok"); + const workspace = path.join(root, "workspace"); + const pluginDir = path.join(grokHome, "plugins", "sample-plugin"); + await writeJson(path.join(pluginDir, "plugin.json"), { + name: "sample-plugin", + displayName: "Sample Plugin", + }); + // Alias the same physical plugin directory via config paths and project tree. + const pluginsRoot = path.join(grokHome, "plugins"); + await writeText( + path.join(grokHome, "config.toml"), + [ + "[plugins]", + `paths = ["${pluginsRoot.replaceAll("\\", "/")}"]`, + "", + ].join("\n"), + ); + await mkdir(path.join(workspace, ".grok"), { recursive: true }); + try { + await symlink(path.join(grokHome, "plugins"), path.join(workspace, ".grok", "plugins")); + } catch { + // Windows without symlink privilege: copy path alias only via config. + } + + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "grok", + grokHome, + workspace, + }); + assert.equal(inventory.plugins.length, 1); + assert.equal(inventory.plugins[0].name, "sample-plugin"); + assert.ok(inventory.plugins[0].installSources.includes("grok-plugins-dir")); + assert.ok(inventory.plugins[0].installSources.includes("grok-plugins-path-config")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("Grok project skills dedupe when .grok/skills symlinks to .agents/skills", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-grok-skill-symlink-")); const grokHome = path.join(root, "home", ".grok"); diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index 2eb7a32..ce16ce6 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -1407,6 +1407,26 @@ test("Grok provider expands user/assistant/tool events and turn_completed usage" }, }, }, + { + method: "_x.ai/session/update", + params: { + update: { + sessionUpdate: "turn_completed", + stop_reason: "end_turn", + // Nested-only shape must still produce model usage (Grok 0.2.x). + usage: { + modelUsage: { + "grok-4": { + inputTokens: 1000, + outputTokens: 200, + totalTokens: 1200, + cachedReadTokens: 50, + }, + }, + }, + }, + }, + }, ]); // contextTokensUsed must not be treated as total token spend await writeFile(path.join(sessionDir, "signals.json"), JSON.stringify({ @@ -1436,10 +1456,15 @@ test("Grok provider expands user/assistant/tool events and turn_completed usage" assert.equal(events.filter((event) => event.type === "metadata.tool_call_update").length, 2); assert.equal(events.filter((event) => event.type === "assistant").length, 1); assert.ok(events.some((event) => event.type === "metadata.thought_chunk")); - const usage = events.find((event) => event.type === "model.response.completed"); - assert.equal(usage?.modelUsage?.inputTokens, 120); - assert.equal(usage?.modelUsage?.outputTokens, 40); - assert.equal(usage?.modelUsage?.totalTokens, 160); + const usageEvents = events.filter((event) => event.type === "model.response.completed"); + assert.equal(usageEvents.length, 2); + assert.equal(usageEvents[0]?.modelUsage?.inputTokens, 120); + assert.equal(usageEvents[0]?.modelUsage?.outputTokens, 40); + assert.equal(usageEvents[0]?.modelUsage?.totalTokens, 160); + assert.equal(usageEvents[1]?.modelUsage?.inputTokens, 1000); + assert.equal(usageEvents[1]?.modelUsage?.outputTokens, 200); + assert.equal(usageEvents[1]?.modelUsage?.totalTokens, 1200); + assert.equal(usageEvents[1]?.model, "grok-4"); assert.ok(!events.some((event) => event.modelUsage?.totalTokens === 44532)); assert.ok(events.some((event) => event.type === "metadata.context_window")); const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); From 07cb50980c08d132129e15c2323eb2238c12380e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=A2=96=E7=90=A8?= Date: Mon, 3 Aug 2026 11:40:07 +0800 Subject: [PATCH 6/6] fix(grok): merge project plugin config and keep render --help usable Merge user and project [plugins] tables for enable lists, and skip platform validation when render --help is requested. Co-authored-by: Grok (Grok 4.5) --- scripts/agent-customize/providers/grok.mjs | 4 ++-- scripts/harness-analysis/render-report.mjs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/agent-customize/providers/grok.mjs b/scripts/agent-customize/providers/grok.mjs index 462f43d..8ef1b8b 100644 --- a/scripts/agent-customize/providers/grok.mjs +++ b/scripts/agent-customize/providers/grok.mjs @@ -399,8 +399,8 @@ export async function collectGrokCustomizeInventory(options = {}) { // Project config may declare plugins/MCP independently of the user home. const userConfigText = includeUserHome ? await readTomlText(configPath) : null; const projectConfigText = await readTomlText(path.join(workspace, ".grok", "config.toml")); - // Prefer user-home [plugins] tables for enable lists; fall back to project config. - const pluginConfigText = userConfigText ?? projectConfigText; + // Merge user then project [plugins] tables so project enabled/disabled lists still apply. + const pluginConfigText = [userConfigText, projectConfigText].filter(Boolean).join("\n"); const [pluginPack, user, project] = await Promise.all([ collectGrokPlugins(grokHome, workspace, pluginConfigText, { includeUserHome }), includeUserHome ? collectGrokUserPrimitives(grokHome) : emptyPrimitives(), diff --git a/scripts/harness-analysis/render-report.mjs b/scripts/harness-analysis/render-report.mjs index c8f2cb7..ab00feb 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -92,7 +92,8 @@ function parseArgs(argv) { const supportedHtmlHosts = new Set([ "qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok", ]); - if (hostId && !supportedHtmlHosts.has(hostId)) { + // Allow --help even when a bad platform is present; validate only for real runs. + if (!options.help && hostId && !supportedHtmlHosts.has(hostId)) { throw Object.assign( new Error(`unsupported render platform: ${hostId}. Supported platforms: ${[...supportedHtmlHosts].join(", ")}.`), { code: "UNSUPPORTED_RENDER_PLATFORM" }, @@ -100,7 +101,7 @@ function parseArgs(argv) { } options.out ??= options.mode === "cursor-canvas" ? ".cursor/better-harness" - : options.mode === "html" && hostId && hostId !== "qoder" + : options.mode === "html" && hostId && supportedHtmlHosts.has(hostId) && hostId !== "qoder" ? `.${hostId}/better-harness` : ".qoder/better-harness"; return options;