diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json new file mode 100644 index 0000000..90040c1 --- /dev/null +++ b/.kimi-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "better-harness", + "version": "0.4.0", + "description": "Build an AI-ready engineering system for safe coding-agent delivery and continuous software improvement.", + "skills": "./skills/", + "interface": { + "displayName": "Better Harness", + "shortDescription": "AI delivery readiness reviews and continuous software improvement" + }, + "author": { + "name": "Qoder", + "email": "dev@qoder.com" + }, + "homepage": "https://github.com/QoderAI/better-harness", + "repository": "https://github.com/QoderAI/better-harness", + "license": "MIT", + "keywords": [ + "skills", + "better-harness", + "ai-delivery", + "continuous-improvement", + "agent-harness", + "change-confidence" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0613202..c6a097a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ observable behavior and compatibility, not every internal refactor. ### Added +- Kimi Code is now a supported analysis-capable source-local host. The + repository installs as a Kimi Code plugin (`/plugins install `) + through a `.kimi-plugin/plugin.json` manifest, gains a Kimi configured-asset + provider (user `~/.kimi-code/skills` and `mcp.json`, project + `.kimi-code/skills` and `.kimi/skills`, and managed plugins from + `plugins/installed.json` with `enabled` filtering and plugin-root path + confinement) plus a Kimi session-evidence adapter that reads + workspace-matching wire transcripts under + `~/.kimi-code/sessions//ses{sion}_*/agents/*/wire.jsonl`, resolving + the workspace mapping through `workspaces.json` and `session_index.jsonl` + with a `wd__*` prefix fallback that records a + `kimi-workspace-index-absent` warning. The public npm package now ships + seven host metadata roots; the Qoder runtime bundle remains Qoder-specific. + - A read-only native Learning Capture review contract can now screen ordinary Task Episodes for repeated exact repair routes, emit a bounded privacy-safe packet, validate evidence-bound `match` or `abstain` decisions, and project diff --git a/README.md b/README.md index f6bfc54..4133512 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ for their explicit boundaries. Better Harness scopes behavior claims to relevant Task Episodes and the surrounding project mechanisms. Qoder and Cursor produce host-native Canvas -reports; Claude Code, Codex, Qwen Code, and GitHub Copilot produce +reports; Claude Code, Codex, Qwen Code, GitHub Copilot, and Kimi Code produce self-contained HTML with paired Markdown. Missing or partial evidence remains explicit. See the [Host Adapter Matrix](docs/adapters/README.md) for current coverage and output differences. @@ -369,6 +369,34 @@ session evidence is read from workspace-matching JSONL transcripts under `~/.pi/agent/sessions/`; missing evidence stays explicit rather than being inferred. +### Kimi Code + +Install the repository as a Kimi Code plugin: + +```text +/plugins install https://github.com/QoderAI/better-harness +``` + +Or install from a local checkout with `/plugins install `. +Plugins install per user and apply to every project; run `/reload` or start a +new session after installing. + +Kimi Code discovers the `better-harness` skill through the +`.kimi-plugin/plugin.json` manifest. A manual install without the plugin +manager also works: copy or symlink this repository's `skills/better-harness` +directory into `~/.kimi-code/skills/` (all projects) or +`/.kimi-code/skills/` (one repository). Start a new Kimi Code +session in the repository you want to review and run the report prompt: + +```text +/skill:better-harness review this project's AI coding workflow and generate a report +``` + +Kimi Code produces a self-contained `report.html` with paired `report.md` and +`findings.json`. Session evidence is read from workspace-matching wire +transcripts under `~/.kimi-code/sessions/`; missing evidence stays explicit +rather than being inferred. + ## Develop and package from source Development requires Node.js `>=22.20.0 <25.0.0` and npm diff --git a/README.zh-CN.md b/README.zh-CN.md index c35a326..33b3976 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,7 +38,7 @@ [公开宿主适配矩阵](docs/docs/hosts/adapter-matrix.md)。 Better Harness 会将行为断言限定在相关的任务过程片段(Task Episode)及其周边项目机制内。 -Qoder 与 Cursor 生成宿主原生 Canvas 报告;Claude Code、Codex、Qwen Code 和 GitHub Copilot 生成自包含的 HTML 报告及配套 Markdown。 +Qoder 与 Cursor 生成宿主原生 Canvas 报告;Claude Code、Codex、Qwen Code、GitHub Copilot 和 Kimi Code 生成自包含的 HTML 报告及配套 Markdown。 缺失或不完整的证据会被明确标注。有关当前覆盖范围和输出差异,请参阅 [宿主适配器矩阵](docs/adapters/README.md)。 @@ -347,6 +347,31 @@ Pi 默认在仓库的 `.pi/better-harness` 报告根目录下生成自包含的 及配套的 `report.md` 和 `findings.json`。Pi 会话证据读自 `~/.pi/agent/sessions/` 下与工作区匹配的 JSONL 会话记录;缺失的证据会被明确标注而不会被推断。 +### Kimi Code + +将本仓库作为 Kimi Code 插件安装: + +```text +/plugins install https://github.com/QoderAI/better-harness +``` + +或在本地检出后使用 `/plugins install <仓库路径>`。插件按用户安装并对所有 +项目生效;安装后请运行 `/reload` 或启动新会话。 + +Kimi Code 通过 `.kimi-plugin/plugin.json` manifest 发现 `better-harness` +Skill。不使用插件管理器也可以手动安装:将本仓库的 `skills/better-harness` +目录复制或软链到 `~/.kimi-code/skills/`(所有项目)或 +`<仓库>/.kimi-code/skills/`(仅当前仓库)。在需要审查的仓库中启动新的 +Kimi Code 会话,运行报告提示词: + +```text +/skill:better-harness 审查此项目的 AI 编码工作流并生成报告 +``` + +Kimi Code 生成自包含的 `report.html` 及配套的 `report.md` 和 +`findings.json`。Kimi Code 会话证据读自 `~/.kimi-code/sessions/` 下与 +工作区匹配的 wire 会话记录;缺失的证据会被明确标注而不会被推断。 + ## 从源码开发和打包 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66a31a8..4fe9dfb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -71,7 +71,7 @@ the target journey, contract, evidence, governance, and DX-measurement system. in the capability-owned agent-customize and session-analysis providers. The Codex shell owns local install/discovery metadata only; Codex evidence collection remains in the capability-owned provider and session-analysis modules. The public npm - package ships all six plugin metadata roots, while the Qoder runtime bundle + package ships all seven plugin metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. ## Developer Experience Routing diff --git a/docs/adapters/README.md b/docs/adapters/README.md index c754205..647e2b0 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -1,10 +1,12 @@ # Host Adapter Matrix This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, -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`, -`docs/adapters/workbuddy.md`, or `docs/adapters/grok.md` by default. +GitHub Copilot, Pi, Kimi Code, 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`, +`docs/adapters/kimi-code.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 @@ -17,13 +19,15 @@ providers, real session-evidence adapters, and output modes. Canonical product judgment stays in `skills/`, `models/`, `references/`, `templates/`, and `scripts//`. -The `@qoderai/better-harness` npm package includes six filesystem metadata -roots for Qoder, Claude Code, Codex, Cursor, Qwen, and Copilot, plus Pi install -metadata in the existing `package.json`. The generated Qoder runtime bundle -includes only the Qoder shell, `.qoder-plugin/`; non-Qoder generated host -artifacts remain source-local. Claude Code installs its shell through the -repository's native marketplace manifest. Pi installs the repository as a pi -package through the `pi` manifest in `package.json`. +The `@qoderai/better-harness` npm package includes seven filesystem metadata +roots for Qoder, Claude Code, Codex, Cursor, Qwen, Copilot, and Kimi Code, +plus Pi install metadata in the existing `package.json`. +The generated Qoder runtime bundle includes only the Qoder shell, +`.qoder-plugin/`; non-Qoder generated host artifacts remain source-local. +Claude Code installs its shell through the repository's native marketplace +manifest. Pi installs the repository as a pi package through the `pi` manifest +in `package.json`. Kimi Code installs the repository as a plugin through the +`.kimi-plugin/plugin.json` manifest. | Host | Positioning | Shell | Configured Assets | Session Evidence | Default Output | Rules / Prompts | Smoke | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -34,6 +38,7 @@ package through the `pi` manifest in `package.json`. | Qwen Code | Analysis-capable source-local host | `qwen-extension.json` | `scripts/agent-customize/providers/qwen.mjs` | `scripts/session-analysis/platforms/qwen.mjs` | self-contained HTML + Markdown | `.qwen` + `QWEN.md` + `AGENTS.md` | `harness prepare --platform qwen` -> finalize with `html-report` validation | | 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 | +| Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> 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 | @@ -96,6 +101,23 @@ package through the `pi` manifest in `package.json`. discovers the canonical root `skills/` directory and the `prompts/` templates through the `pi` manifest in `package.json`; that manifest is install/discovery metadata and does not own Pi evidence collection. +- Kimi Code configured assets are inventoried through + `scripts/agent-customize/providers/kimi.mjs`: user-level + `~/.kimi-code/skills/**/SKILL.md` and `~/.kimi-code/mcp.json`, plus + project-level `AGENTS.md`/`CLAUDE.md` and the probed skill roots + `.kimi-code/skills/**/SKILL.md` and `.kimi/skills/**/SKILL.md`. The + repository's `.kimi-plugin/plugin.json` manifest makes Better Harness + installable through Kimi Code's `/plugins` manager. Kimi Code also + supports hooks, custom agents, plugin-declared slash commands, and + plugin-bundled skills (installed per user under + `~/.kimi-code/plugins/managed/`); the provider inventories those surfaces + for plugins recorded in `~/.kimi-code/plugins/installed.json` (assets only + for `enabled: true` records), while memory has no Kimi Code equivalent. + Session evidence comes + from `scripts/session-analysis/platforms/kimi.mjs`, which reads + `~/.kimi-code/sessions//ses{sion}_*/agents/*/wire.jsonl` and resolves + the workspace-to-`wd_*` mapping through `workspaces.json` and + `session_index.jsonl` (falling back to `wd__*` directory prefixes). - WorkBuddy configured assets are inventoried through `scripts/agent-customize/providers/workbuddy.mjs`, covering `~/.workbuddy` user skills, marketplace plugins under `plugins/marketplaces/` with enabled @@ -130,7 +152,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/Grok visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/Kimi Code/WorkBuddy/Grok visual output contract, covering `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. diff --git a/docs/adrs/directory-structure.md b/docs/adrs/directory-structure.md index 2faa288..5a837b1 100644 --- a/docs/adrs/directory-structure.md +++ b/docs/adrs/directory-structure.md @@ -93,7 +93,7 @@ scripts/ core-change-watch/ # [active] static structure/core-path/history evidence session-analysis.mjs # [active] thin shim; new exports -> scripts/session-analysis/ session-analysis/ # [active] session evidence collection/normalization - platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen/Copilot/Pi/WorkBuddy host adapters + platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen/Copilot/Pi/Kimi/WorkBuddy host adapters ides// # target editor-local evidence not covered by host adapters / # [target] new capability owner cli.mjs # use cli.mjs for new capabilities @@ -247,7 +247,7 @@ Use the tree first. These rules resolve common collisions: `.cursor-plugin/`, `.codex-plugin/`, and `.github/plugin/` are install/discovery shells for one host. Existing active shells may be hand-maintained narrowly, but the Qoder - public npm package ships all six plugin metadata roots, while the Qoder + public npm package ships all seven plugin metadata roots, while the Qoder runtime bundle ships only `.qoder-plugin/`. New host shells start from the `docs/adapters/README.md` matrix; split to `docs/adapters/.md` and add a source-local `scripts/packaging/` builder only for an accepted host-artifact diff --git a/docs/community.md b/docs/community.md index bc8a419..9596431 100644 --- a/docs/community.md +++ b/docs/community.md @@ -37,7 +37,7 @@ This is the complete reference. For the common cases, see Start Here above. | Style grammar | Yes | `templates/style/` | Directive-only visual language; no runnable skeletons | Selected by report/style routing | Style-template tests and no copied runtime skeletons | | Structured knowledge | Candidate only | `knowledge-base/{official,community}/...` | `knowledge.md`, interim `schema.json`, fixtures, namespace uniqueness | Docs-only until registry spec, compiler, and binding tests exist | Namespace check, schema/fixture review, migration note | | Examples and operating models | Yes | `case-studies/` | Named example, scope, evidence boundary, non-runtime status | Reference material only unless separately bound | Link/path check; no runtime-policy claims | -| Host shell and packaging | Thin, or generated only after a split trigger | `.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, the `pi` manifest in `package.json`, future lifecycle shells | Install/discovery metadata and pointers to canonical owners | Public npm package includes all six current metadata roots; the Qoder runtime bundle includes only `.qoder-plugin/`, and generated host artifacts stay source-local | `scripts/npm-package/` verification, or split adapter note plus target builder | +| Host shell and packaging | Thin, or generated only after a split trigger | `.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, `.kimi-plugin/`, the `pi` manifest in `package.json`, future lifecycle shells | Install/discovery metadata and pointers to canonical owners | Public npm package includes all seven current metadata roots; the Qoder runtime bundle includes only `.qoder-plugin/`, and generated host artifacts stay source-local | `scripts/npm-package/` verification, or split adapter note plus target builder | ## Non-Extension Boundaries diff --git a/docs/concepts.md b/docs/concepts.md index 3f0be6c..5cd9629 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -73,7 +73,7 @@ until you need diagnosis. See [../models/routing.md](../models/routing.md). | Project evidence | `better-harness core-change-watch` | Project, history, core-path, and diff signals | | Change confidence | `hooks/git-scripts/blast-radius` | Symbol-graph blast radius of a change | | Dependency governance | `better-harness dependency-governance` | Update automation, audit, stale-dep signals | -| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or WorkBuddy session behavior | +| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, Kimi Code, or WorkBuddy session behavior | | Agent assets | `better-harness coding-agent-practices inventory` | Inventory configured agent surfaces | | Guardrails | `hooks/`, `scripts/agent-guardrails` | Secret scanning and lifecycle checks | diff --git a/docs/glossary.md b/docs/glossary.md index c7c380b..cb5fb5f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -60,7 +60,7 @@ for extension surfaces, read [community.md](community.md). | `core-change-watch` | Project, history, core-path, and current-diff evidence collection. | [scripts/core-change-watch](../scripts/core-change-watch) | | Blast radius | The symbol-graph reach of a change, computed with tree-sitter (JS/TS, Go, Python) as a git hook. | [hooks/git-scripts/blast-radius](../hooks/git-scripts/blast-radius) | | `dependency-governance` | Update-automation, audit, and stale-dependency signals. | [scripts/dependency-governance](../scripts/dependency-governance) | -| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or WorkBuddy agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | +| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, Kimi Code, or WorkBuddy agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | | Guardrails | Change-time enforcement: secret scanning and lifecycle hook checks. | [hooks](../hooks), [scripts/agent-guardrails](../scripts/agent-guardrails) | ## The Action Loop (Report → Change) @@ -79,7 +79,7 @@ for extension surfaces, read [community.md](community.md). |---|---|---| | Skill | A repeatable agent workflow defined by `SKILL.md` frontmatter plus a concise workflow. | [community.md](community.md); report use: [report contract](../skills/better-harness/SKILL.md#report-output) | | Host adapter | Per-host discovery and evidence-shape glue (e.g. Qoder, Codex); keeps the engine host-neutral. | [adapters/README.md](adapters/README.md) | -| Host shell | Thin host metadata (`.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, the `pi` manifest in `package.json`, or a future lifecycle shell) that exposes canonical behavior without owning product logic; the public npm package ships all six current metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. | [ARCHITECTURE.md](ARCHITECTURE.md) | +| Host shell | Thin host metadata (`.claude-plugin/`, `.qoder-plugin/`, `.cursor-plugin/`, `.codex-plugin/`, `.github/plugin/`, `qwen-extension.json`, `.kimi-plugin/`, the `pi` manifest in `package.json`, or a future lifecycle shell) that exposes canonical behavior without owning product logic; the public npm package ships all seven current metadata roots, while the Qoder runtime bundle includes only `.qoder-plugin/`. | [ARCHITECTURE.md](ARCHITECTURE.md) | | Canonical owner | The single directory that owns a behavior's product judgment; host shells and mirrors point back to it. | [ARCHITECTURE.md](ARCHITECTURE.md) | ## "I Want To… → Use" diff --git a/docs/specs/2026-07-30-kimi-host-support.md b/docs/specs/2026-07-30-kimi-host-support.md new file mode 100644 index 0000000..a15fb8b --- /dev/null +++ b/docs/specs/2026-07-30-kimi-host-support.md @@ -0,0 +1,192 @@ +# Add Kimi Code as a supported host + +## Traceability + +- Spec ID: `kimi-host-support` +- Status: Implemented +- Contribution workflow: [Contributing a New Coding Agent Host](../adapters/contributing-new-coding-agent.md) + +## Intent + +Make Kimi Code a first-class, evidence-safe Better Harness host alongside +Qoder, Codex, Claude Code, Cursor, Qwen Code, GitHub Copilot, and Pi. + +This contribution reaches the **Verified install/discovery** capability level: +native discovery, configured assets, session evidence, evidence-bundle routing, +and portable report routing are verified. Kimi Code remains outside the public +Quickstart set until a full report render is validated end to end in Kimi Code, +as required by the repository's host contribution contract. + +Kimi Code is a terminal coding agent extended through Agent Skills, MCP +servers, custom agents, hooks, and plugins installed through `/plugins`. It +implements the Agent Skills standard and discovers a plugin package's skills +through the `.kimi-plugin/plugin.json` manifest, so +`/plugins install ` can load the canonical `better-harness` Skill today, +and a manual copy of `skills/better-harness` into `~/.kimi-code/skills` or a +project `.kimi-code/skills` directory works without the plugin manager. The +gaps this spec closes are the missing evidence: every provider enum rejected +`kimi`, so the workflow could neither inventory Kimi Code's configured assets +nor read Kimi Code session transcripts, leaving a Kimi Code user unable to get +a Kimi-scoped Harness report. + +Kimi Code's native contracts were verified against the local Kimi Code +installation: the configuration root is `~/.kimi-code` (overridable through +`--kimi-home`); sessions are wire transcripts at +`~/.kimi-code/sessions//ses{sion}_*/agents//wire.jsonl` with a +sibling `state.json` carrying title and created/updated timestamps; the +workspace-to-`wd_*` mapping resolves through `~/.kimi-code/workspaces.json` +(exact `root` match) and `~/.kimi-code/session_index.jsonl` (per-session +`workDir`), falling back to `wd__*` directory prefixes with a +`kimi-workspace-index-absent` warning only when both indexes are absent. Wire +records normalize from `context.append_loop_event` (`tool.call`/`tool.result`/ +`content.part`), `turn.prompt`/`turn.steer`, `context.append_message` +(protocol 1.0), and `usage.record` token usage; unknown record types degrade to +bounded `metadata.*` events for forward compatibility. + +## Acceptance Scenarios + +- **KHS-AC-1 (native shell):** The repository declares a + `.kimi-plugin/plugin.json` manifest with the canonical `skills` pointer, so + `/plugins install ` discovers the root `skills/` Skill and + `/skill:better-harness` invokes it. The manifest ships in the public npm + package as the seventh filesystem metadata root; the Qoder runtime bundle + stays Qoder-specific. +- **KHS-AC-2 (configured assets):** `agent-customize` supports `--provider + kimi` through a capability-owned provider module that inventories user-level + `~/.kimi-code/skills/**/SKILL.md` and `~/.kimi-code/mcp.json#mcpServers`, + project-level `.kimi-code/skills/**/SKILL.md` and `.kimi/skills/**/SKILL.md`, + and project `AGENTS.md`/`CLAUDE.md` context. Repository-root `skills/` is the + Better Harness distribution layout, not a Kimi Code project convention, and + is deliberately not probed. +- **KHS-AC-3 (plugin inventory):** Installed plugins indexed in + `~/.kimi-code/plugins/installed.json` are inventoried from their managed + copies under `~/.kimi-code/plugins/managed//`, reading `kimi.plugin.json` + first and falling back to `.kimi-plugin/plugin.json`. Assets are inventoried + only for records with `enabled: true`, and manifest-declared paths that + escape the plugin root are skipped. `walkFiles` follows symlinks so + symlink-installed skills are discovered; collected files whose realpath + escapes the plugin root through such symlinks are dropped from the + inventory. +- **KHS-AC-4 (evidence boundaries):** Kimi Code has no memory equivalent, and + memory inventory is declared unsupported rather than approximated. + `~/.kimi-code/config.toml` holds model/provider settings and is surfaced only + as a diagnostics flag, never parsed into inventory items. The MCP collector + reads `mcp.json` only and never surfaces environment values, header values, + URL credentials, or authentication state; `~/.kimi-code/credentials` and + `server.token` are never read. +- **KHS-AC-5 (session evidence):** `session-analysis` supports `--platform + kimi` through a capability-owned platform module that reads + workspace-matching wire transcripts, resolves the workspace-to-`wd_*` mapping + through `workspaces.json` and `session_index.jsonl`, and falls back to + `wd__*` directory prefixes only when both indexes are absent, recording + a `kimi-workspace-index-absent` warning. User text, command text, and message + content stay gated behind the shared `includeUserText`/`includeCommandText`/ + `includeContent` privacy flags, and session ids and home paths never enter + production facts. +- **KHS-AC-6 (bundle propagation):** `harness evidence-bundle --platform kimi` + freezes a Kimi Code context and returns all three lanes, and `--kimi-home` + routes isolated configuration paths into the Agent Customize lane, selection + profile, and task-loop analysis through both the collector API and the + relevant CLIs. +- **KHS-AC-7 (host routing):** The canonical host adapter matrix carries a Kimi + Code row with discovery paths, evidence sources, default output, and a smoke + command. Canonical portable HTML routing includes Kimi Code, and Qoder + remains the only Canvas host. Public Quickstart surfaces remain unchanged + until Kimi Code has an end-to-end report-render smoke. +- **KHS-AC-8 (support-declaration consistency):** `kimi` is a member of the + canonical supported-platform set, so the provider registry, session platform + loader, CLI help, report gate, asset-baseline gate, and adapter matrix all + agree (A-06). +- **KHS-AC-9 (provider behavior):** Deterministic fixtures cover Kimi Code + asset inventory, plugin `enabled` filtering and plugin-root path confinement, + symlink-followed skill discovery, wire-record normalization, workspace-index + resolution and prefix fallback, and privacy-gated text extraction. +- **KHS-AC-10 (documentation integrity):** Markdown links and the generated + documentation routing graph remain current, and the detailed Installation + sections in both READMEs document the `/plugins install` path and the manual + skills-directory fallback without promoting Kimi Code into the public + Quickstart set. + +## Non-goals + +- Inventory Kimi Code custom agents, plugin-declared `agents`/`commands`/ + `hooks` beyond plugin metadata, or plugin `systemPrompt` content merged into + rules; plugin prompt content stays plugin metadata. +- Add a Kimi Code memory inventory. There is no memory equivalent, so memory + stays declared unsupported rather than over-reporting. +- Parse `~/.kimi-code/config.toml` into inventory items. +- Split a `docs/adapters/kimi-code.md`; no split trigger is met. +- Add a `scripts/packaging/` Kimi Code host-artifact builder. Kimi Code + installs from the checked-in `.kimi-plugin/plugin.json` manifest and needs no + generated shell. +- Add Kimi Code metadata to the Qoder runtime bundle. +- Promote Kimi Code to the README Quickstart list, Docusaurus home-page cards, + installation tabs, or public adapter matrix before a full report render is + validated end to end in Kimi Code. +- Treat configured Kimi Code assets, zero candidates, or a loaded Skill as + proof of runtime quality or Skill invocation. + +## Plan and Tasks + +1. Add `.kimi-plugin/plugin.json` as the native plugin manifest and ship it in + the public npm package files list and packaging verifier. (KHS-AC-1) +2. Add `scripts/agent-customize/providers/kimi.mjs` as the capability owner and + register it in the provider registry, covering user skills and MCP, project + skill roots, `AGENTS.md`/`CLAUDE.md` context, and managed-plugin inventory + with `enabled` filtering and plugin-root path confinement. (KHS-AC-2, + KHS-AC-3, KHS-AC-4) +3. Add `scripts/session-analysis/platforms/kimi.mjs` and register it in both + the public `session-analysis.mjs` dispatch and the capability-owned + `analyzer.mjs` dispatch, with `workspaces.json`/`session_index.jsonl` + workspace resolution and the `wd__*` prefix fallback. (KHS-AC-5) +4. Register `kimi` across the remaining provider enums and `--kimi-home` + threading: evidence-bundle contract and lanes, report run, task-loop source + and report, report quality, coding-agent-practices asset baseline, asset + integrity, and inventory, lifecycle demand signals, selection profile, usage + summary, and agent-lint usage. (KHS-AC-6, KHS-AC-8) +5. Add `references/agent-customize/platforms/kimi.md`, a Kimi asset route, and + a Kimi Code section in Session Diagnostics. (KHS-AC-2, KHS-AC-5) +6. Add the Kimi Code row to the canonical adapter matrix, keep the + metadata-root counts across the architecture, community, glossary, and + concepts docs, and route Kimi Code through portable HTML reporting without + changing the public Quickstart set. (KHS-AC-7) +7. Document the `/plugins install` path and the manual skills-directory + fallback in the detailed Installation sections of both READMEs. (KHS-AC-10) +8. Extend tests and packaging verification for the new manifest, provider, + platform, enums, and the supported-platform consistency set. (KHS-AC-8, + KHS-AC-9, KHS-AC-10) + +## Test and Review Evidence + +Confirmed on the final integration diff: + +- `node --test test/coding-agent-platform-notes.test.mjs + test/plugin-manifests.test.mjs test/docs-entrypoints.test.mjs + test/support-declarations.test.mjs test/scripts-refactor-contract.test.mjs + test/agent-asset-baseline.test.mjs + test/better-harness-evidence-bundle.test.mjs test/agent-customize.test.mjs`: + focused provider, manifest, documentation, and consistency tests passed. +- `npm test`: no new failures beyond the pre-existing environment-dependent + baseline failures. +- `node scripts/agent-customize/cli.mjs --help` lists `kimi` in the provider + set and `--kimi-home` in the home overrides. + +Not observed on the final integration diff: a model-driven, end-to-end +`/skill:better-harness` report render inside an interactive Kimi Code session. +Public Quickstart promotion therefore remains deferred. + +## Risk + +Kimi Code's wire transcript format and session directory contract are observed +from a local installation rather than a published schema. The platform module +degrades unknown record types to bounded `metadata.*` events and keeps coverage +explicit instead of inferring activity, so a Kimi Code change surfaces as a +failing contract rather than silent misrouting. + +## Unknowns + +- [NEEDS CLARIFICATION: whether Kimi Code publishes a versioned schema for the + wire transcript format that the platform test should validate against.] +- [NEEDS CLARIFICATION: whether Kimi Code will expose custom-agent inventory + natively, at which point the Agents collection could move off the + not-yet-inventoried boundary.] diff --git a/package.json b/package.json index 687bc1b..d77cd69 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ ".cursor-plugin/", ".github/plugin/", ".qoder-plugin/", + ".kimi-plugin/", "qwen-extension.json", "AGENTS.md", "CHANGELOG.md", diff --git a/references/agent-customize/README.md b/references/agent-customize/README.md index 2dec1cb..e94cce3 100644 --- a/references/agent-customize/README.md +++ b/references/agent-customize/README.md @@ -16,7 +16,7 @@ authority, routing, overlap, observed use, and maintenance boundaries. - Inventory and authority: `global-assets.md`. - Provider-specific notes: `platforms/claude.md`, `platforms/codex.md`, `platforms/qoder.md`, `platforms/qwen.md`, `platforms/copilot.md`, - `platforms/pi.md`, and `platforms/workbuddy.md`. + `platforms/pi.md`, `platforms/kimi.md`, and `platforms/workbuddy.md`. ## Does Not Own diff --git a/references/agent-customize/global-assets.md b/references/agent-customize/global-assets.md index 77c2c70..2291734 100644 --- a/references/agent-customize/global-assets.md +++ b/references/agent-customize/global-assets.md @@ -1,21 +1,22 @@ # Global Coding-Agent Assets Use this reference when a readiness run, screenshot, or user request points to -Cursor, Qoder, Codex, Claude, Qwen, or Copilot settings, installed assets, -global skills, user hooks, commands, agents, plugins, MCPs, or memories. Treat -this as a configured asset inventory, not a session behavior report. +Cursor, Qoder, Codex, Claude, Qwen, Copilot, or Kimi Code settings, installed +assets, global skills, user hooks, commands, agents, plugins, MCPs, or +memories. Treat this as a configured asset inventory, not a session behavior +report. ## Scope - Project assets: `.cursor`, `.qoder`, `.codex`, `.claude`, `.agents`, - `.github`, project rules, skills, agents, commands, hooks, workflows, - settings, and MCP config. -- User/global assets: `~/.{cursor,qoder,codex,claude,qwen,copilot}` skills, - hooks, commands, agents, rules, settings, and MCP config. + `.github`, `.kimi-code`, `.kimi`, project rules, skills, agents, commands, + hooks, workflows, settings, and MCP config. +- User/global assets: `~/.{cursor,qoder,codex,claude,qwen,copilot,kimi-code}` + skills, hooks, commands, agents, rules, settings, and MCP config. - Plugin/marketplace assets: provider plugin caches and install evidence under - `~/.cursor`, `~/.qoder`, `~/.codex`, `~/.claude`, `~/.qwen`, and - `~/.copilot`, including plugin-declared Skills, MCPs, Commands, Hooks, Rules, - and Subagents. + `~/.cursor`, `~/.qoder`, `~/.codex`, `~/.claude`, `~/.qwen`, `~/.copilot`, + and `~/.kimi-code`, including plugin-declared Skills, MCPs, Commands, Hooks, + Rules, and Subagents. - Memories: `~/.qoder/memories/**` plus Qoder `SharedClientCache` `app-config.json` memory keys and `cache/db/*.db*` file presence; and Codex generated-memory metadata under `~/.codex/memories/` plus supported @@ -28,15 +29,15 @@ this as a configured asset inventory, not a session behavior report. Run the read-only inventory when user-home or installed assets are in scope: ```bash - /scripts/agent-customize/cli.mjs inventory --provider --workspace - /scripts/coding-agent-practices/inventory.mjs --workspace --include-user-home --include-memories --format markdown - coding-agent-practices asset-integrity --workspace --language --json [--include-memories] [--include-user-home] + /scripts/agent-customize/cli.mjs inventory --provider --workspace + /scripts/coding-agent-practices/inventory.mjs --workspace --include-user-home --include-memories --format markdown + coding-agent-practices asset-integrity --workspace --language --json [--include-memories] [--include-user-home] ``` Use `--cursor-home `, `--qoder-home `, `--codex-home `, `--claude-home `, `--qwen-home `, `--copilot-home `, -`--claude-state `, `--codex-app-path `, or `--shared-cache ` -for fixtures, alternate +`--kimi-home `, `--claude-state `, `--codex-app-path `, or +`--shared-cache ` for fixtures, alternate installs, or non-standard homes. Use the `agent-customize` command as the provider-specific configured asset source of truth; use the `coding-agent-practices` wrapper when the report also needs the matrix shape or @@ -46,7 +47,9 @@ the active `SharedClientCache` MCP home, excludes runtime-only project MCP metadata, and omits zero-count rows. For Claude-specific settings/state/Plugin precedence and privacy boundaries, -continue with [Claude Code Configured Assets](platforms/claude.md). +continue with [Claude Code Configured Assets](platforms/claude.md). For Kimi +Code configured-asset locations and evidence boundaries, continue with +[Kimi Code Configured Assets](platforms/kimi.md). The provider-labelled asset-integrity command reuses that inventory for a lightweight second pass. It checks Memory filename-title collisions/similarity, enabled diff --git a/references/agent-customize/platforms/kimi.md b/references/agent-customize/platforms/kimi.md new file mode 100644 index 0000000..c836bc4 --- /dev/null +++ b/references/agent-customize/platforms/kimi.md @@ -0,0 +1,88 @@ +# Kimi Code Configured Assets + +Use this note for Kimi Code-specific configured-asset locations and evidence +boundaries. Start from [Agent Customize Routing](../routing.md) for owner +selection and [Global Coding-Agent Assets](../global-assets.md) for the shared +inventory contract. + +## Static Inventory Route + +```bash + /scripts/agent-customize/cli.mjs inventory \ + --provider kimi \ + --workspace + + /scripts/coding-agent-practices/asset-baseline.mjs kimi \ + --workspace \ + --include-user-home \ + --format json +``` + +Use `--kimi-home ` for an isolated configuration root +(default: `~/.kimi-code`). + +## Source Map + +| Asset | User | Selected project | Installed Plugin | +|---|---|---|---| +| Instructions / Rules | None | `AGENTS.md`, `CLAUDE.md` (compat) | Plugin `systemPrompt`/`systemPromptPath` (plugin metadata only, never merged into rules) | +| Skills | `~/.kimi-code/skills/**/SKILL.md` | `.kimi-code/skills/**/SKILL.md`, `.kimi/skills/**/SKILL.md` (probed) | Plugin-declared `skills` roots under `~/.kimi-code/plugins/managed//` (root `SKILL.md` when undeclared) | +| MCP | `~/.kimi-code/mcp.json#mcpServers` | None | Plugin-declared `mcpServers` from `kimi.plugin.json` | +| Agents / Commands / Hooks | Custom agents (not yet inventoried) | Custom agents (not yet inventoried) | Plugin `agents`, `commands`, and `hooks` from `kimi.plugin.json` | +| Memory | No Kimi Code equivalent | No Kimi Code equivalent | No Kimi Code equivalent | + +`~/.kimi-code/config.toml` holds model/provider settings, not customizable +assets; it is surfaced only as a diagnostics flag and never parsed into +inventory items. + +Installed plugins are indexed in `~/.kimi-code/plugins/installed.json`; each +record points at a managed copy under `~/.kimi-code/plugins/managed//`. +The provider reads `kimi.plugin.json` first and falls back to +`.kimi-plugin/plugin.json`, inventories assets only for records with +`enabled: true` (disabled plugins stay listed without component assets), and +skips manifest-declared paths that escape the plugin root. Collection follows +symlinks, so any collected file whose realpath escapes the plugin root (for +example through a symlink inside it) is dropped from the inventory as well. + +## Skill Layout And Invocation + +Repository-root `skills/` is the Better Harness distribution layout, not a +Kimi Code project-level convention. The provider deliberately does not probe +it, so a workspace-only inventory can report `skills: 0` for a repository +whose skills are physically present in that root directory. A skill counts +only after installation into user scope (`~/.kimi-code/skills`) or project +scope (`.kimi-code/skills`, `.kimi/skills`). + +Skills are invoked with `/skill:` (for example `/skill:better-harness`) +or automatically by the model, so the skill surface doubles as the main +invocation surface. Plugins can additionally register namespaced slash +commands (`:`) from a `commands/` directory declared in +`kimi.plugin.json`; commands declared by enabled plugins are inventoried into +the Commands collection. + +## Session Evidence Boundary + +Session evidence comes from `scripts/session-analysis/platforms/kimi.mjs`: + +- Transcripts live at `~/.kimi-code/sessions//ses{sion}_*/agents//wire.jsonl`; + `state.json` next to them supplies title and created/updated timestamps. +- The workspace-to-`wd_*` mapping is resolved through + `~/.kimi-code/workspaces.json` (exact `root` match) and + `~/.kimi-code/session_index.jsonl` (per-session `workDir`). Only when both + indexes are absent does the adapter fall back to `wd__*` directory + prefixes, and it records a `kimi-workspace-index-absent` warning. +- Wire records are normalized from `context.append_loop_event` + (`tool.call`/`tool.result`/`content.part`), `turn.prompt`/`turn.steer`, + `context.append_message` (protocol 1.0), and `usage.record` token usage. + Unknown record types degrade to bounded `metadata.*` events for forward + compatibility (`metadata.protocol_version` is currently `1.4`). +- User text, command text, and message content stay gated behind the shared + `includeUserText`/`includeCommandText`/`includeContent` privacy flags. +- `~/.kimi-code/credentials` and `server.token` are never read. + +## MCP And Privacy Boundary + +The static collector reads `mcp.json` only. Output may include server name, +transport, command, safe argument metadata, environment key names, and +direct-secret-key warnings. It must not contain environment values, header +values, URL credentials, or authentication state. diff --git a/references/agent-customize/routing.md b/references/agent-customize/routing.md index 13b3c23..3c117e4 100644 --- a/references/agent-customize/routing.md +++ b/references/agent-customize/routing.md @@ -31,12 +31,13 @@ Route by ownership before choosing a vendor-specific feature: - Agent guides (`AGENTS.md`, `CLAUDE.md`, Copilot, Cursor, Qoder rules) -> `agents-md-review.md`. -- Cursor/Qoder/Codex/Claude/Qwen/Copilot project or user assets -> +- Cursor/Qoder/Codex/Claude/Qwen/Copilot/Kimi project or user assets -> `global-assets.md`; for Claude-specific configured-asset scope, then load `platforms/claude.md`; for Codex-specific operating practice, then load `platforms/codex.md`; for Qoder-specific feature taxonomy, then load `platforms/qoder.md`; for Copilot-specific operating practice, then load - `platforms/copilot.md`. For installed, user-home, settings screenshot, plugin + `platforms/copilot.md`; for Kimi-specific configured-asset scope, then load + `platforms/kimi.md`. For installed, user-home, settings screenshot, plugin cache, or memory scope, run the Global/User Asset Pass. - Prior decision, user correction, remembered preference, stale recall, cross-window adoption, or memory-safety question -> `memory-review.md` after @@ -401,6 +402,39 @@ user asks about Pi global assets such as `~/.pi/agent/skills`, installed pi packages, or extensions. Keep configured inventory evidence separate from observed session behavior. +## Kimi Asset Route + +For Kimi Code-specific actions, use `platforms/kimi.md` as the operating +practice reference for configured-asset locations, session evidence, MCP and +privacy boundaries, and plugin inventory. Presence is not execution proof. + +Inspect configured surfaces before projecting readiness evidence: + +- `AGENTS.md` (project and ancestors) and `CLAUDE.md` (compatibility) for + durable repo context. Plugin-declared `systemPrompt`/`systemPromptPath` + content stays plugin metadata and is never merged into rules. +- `~/.kimi-code/skills/**/SKILL.md` for user-scope workflows and project + `.kimi-code/skills/**/SKILL.md` plus `.kimi/skills/**/SKILL.md` for + repository-scope workflows. Skills are invoked with `/skill:` or + triggered automatically from their descriptions, so the skill surface + doubles as the main invocation surface. +- `~/.kimi-code/mcp.json#mcpServers` for external context. The collector + reads `mcp.json` only and never surfaces environment values, header + values, URL credentials, or authentication state. +- Installed plugins indexed in `~/.kimi-code/plugins/installed.json`, each + record pointing at a managed copy under `~/.kimi-code/plugins/managed//`. + Assets are inventoried only for `enabled: true` records, from + `kimi.plugin.json` (falling back to `.kimi-plugin/plugin.json`), and + manifest-declared paths that escape the plugin root are skipped. +- Session, diff, test, build, and review evidence for observed execution. + +Kimi Code has no memory equivalent, and `~/.kimi-code/config.toml` holds +model/provider settings rather than customizable assets, so it is surfaced +only as a diagnostics flag. Use the Global/User Asset Pass from +`global-assets.md` when the user asks about Kimi Code global assets such as +`~/.kimi-code/skills`, `~/.kimi-code/mcp.json`, or installed plugins; pass +`--kimi-home ` for an isolated configuration root. Keep configured +inventory evidence separate from observed session behavior. ## WorkBuddy Asset Route For WorkBuddy-specific actions, use `platforms/workbuddy.md` as the operating diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index 0226d69..9491635 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`, `workbuddy`, and `grok`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, `kimi`, `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. @@ -308,6 +308,32 @@ tool results as provider-labelled coverage. Entries such as `model_change`, project/user assets through `../agent-customize/global-assets.md`; configured presence does not prove use. +### Kimi Code + +For Kimi Code, the analyzer reads workspace-matching wire transcripts under +`~/.kimi-code/sessions//ses{sion}_*/agents//wire.jsonl`, with +`state.json` next to each transcript supplying title and created/updated +timestamps. The workspace-to-`wd_*` mapping resolves through +`~/.kimi-code/workspaces.json` (exact `root` match) and +`~/.kimi-code/session_index.jsonl` (per-session `workDir`); only when both +indexes are absent does the adapter fall back to `wd__*` directory +prefixes and record a `kimi-workspace-index-absent` warning. Wire records are +normalized from `context.append_loop_event` (`tool.call`/`tool.result`/ +`content.part`), `turn.prompt`/`turn.steer`, `context.append_message` +(protocol 1.0), and `usage.record` token usage; unknown record types degrade +to bounded `metadata.*` events for forward compatibility. + +Treat wire-record timestamps, `usage.record` token usage, tool calls, and +tool results as provider-labelled coverage. User text, command text, and +message content stay gated behind the shared `includeUserText`, +`includeCommandText`, and `includeContent` privacy flags, and +`~/.kimi-code/credentials` and `server.token` are never read. Route +configured Kimi Code rules (`AGENTS.md`, `CLAUDE.md`), Skills +(`~/.kimi-code/skills`, project `.kimi-code/skills` and `.kimi/skills`), MCP +(`~/.kimi-code/mcp.json`), and installed plugins +(`~/.kimi-code/plugins/installed.json`) through +`../agent-customize/global-assets.md`; configured presence does not prove +use. ### WorkBuddy For WorkBuddy, the analyzer reads workspace-matching JSONL transcripts under diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index 3dded47..796edb8 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, --grok-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --copilot-home, --pi-home, --kimi-home, --workbuddy-home, --grok-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } @@ -35,6 +35,7 @@ function summarize(inventory, options) { workbuddyHome: inventory.workbuddyHome, grokHome: inventory.grokHome, claudeStatePath: inventory.claudeStatePath, + kimiHome: inventory.kimiHome, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, workspace: inventory.workspace, @@ -81,6 +82,7 @@ async function main() { workbuddyHome: options["workbuddy-home"], grokHome: options["grok-home"], claudeStatePath: options["claude-state"] ?? options["claude-state-path"], + kimiHome: options["kimi-home"], codexAppPath: options["codex-app-path"], qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"], workspace: options.workspace, diff --git a/scripts/agent-customize/core/items.mjs b/scripts/agent-customize/core/items.mjs index 099d0d7..b83eaec 100644 --- a/scripts/agent-customize/core/items.mjs +++ b/scripts/agent-customize/core/items.mjs @@ -15,7 +15,7 @@ export function sortByName(left, right) { } function ruleSourceRank(item) { - if (item.sourceKind === "design-md-contract") { + if (item.sourceKind === "design-md-contract" || item.precedence === "after-agents-md") { return 30; } if (item.sourceKind === "agents-md-compat" || item.precedence === "after-qoder-rules") { @@ -140,6 +140,32 @@ export async function pluginMetadataEvidencePath(pluginRoot, relativeCandidates) return pluginRoot; } +export function pluginPathValues(value) { + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()); + return []; +} + +export async function pathInsideRoot(root, relativePath) { + if (!root || typeof relativePath !== "string" || !relativePath.trim()) return undefined; + const base = path.resolve(root); + const candidate = path.resolve(base, relativePath); + const lexicalRelative = path.relative(base, candidate); + if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) { + return undefined; + } + if (!(await pathExists(candidate))) return undefined; + const [realBase, realCandidate] = await Promise.all([ + realpath(base).catch(() => base), + realpath(candidate).catch(() => candidate), + ]); + const resolvedRelative = path.relative(realBase, realCandidate); + if (resolvedRelative === ".." || resolvedRelative.startsWith(`..${path.sep}`) || path.isAbsolute(resolvedRelative)) { + return undefined; + } + return candidate; +} + export async function collectSkillFiles(root, scope, sourceLabel, rootForEvidence = root) { if (!(await pathExists(root))) { return []; @@ -148,6 +174,10 @@ export async function collectSkillFiles(root, scope, sourceLabel, rootForEvidenc maxDepth: 5, limit: 5000, match: (filePath) => path.basename(filePath) === "SKILL.md", + // Support skills installed via symlinks (such as the better-harness + // recommendation to junction/symlink the skills directory into the + // user-level skills directory). + followSymlinks: true, }); const items = []; for (const filePath of files) { @@ -209,6 +239,28 @@ export async function uniqueAssetsByRealPath(items) { return result; } +function containmentPath(value) { + let resolved = path.resolve(value).replace(/\\/gu, "/"); + if (process.platform === "win32") resolved = resolved.toLowerCase(); + return resolved.replace(/\/+$/u, ""); +} + +// Drop collected items whose realpath escapes the given root. Collection may +// follow symlinks (collectSkillFiles does), so a symlink inside the root can +// otherwise pull files from outside it into the inventory. +export async function filterItemsInsideRoot(items, root) { + const realBase = containmentPath(await realpath(path.resolve(root)).catch(() => path.resolve(root))); + const result = []; + for (const item of items) { + const filePath = item.filePath ?? item.evidence?.path; + if (!filePath) continue; + const realItem = containmentPath(await realpath(filePath).catch(() => path.resolve(filePath))); + if (realItem !== realBase && !realItem.startsWith(`${realBase}/`)) continue; + result.push(item); + } + return result; +} + export async function collectMarkdownRuleItems(root, scope, sourceLabel, rootForEvidence = root, options = {}) { return (await collectMarkdownItems(root, "rule", scope, sourceLabel, rootForEvidence)) .map((item) => ({ @@ -578,6 +630,62 @@ export async function collectMcpItems(root, scope, sourceLabel, rootForEvidence return items.sort(sortByName); } +function sanitizeMcpUrl(value) { + if (typeof value !== "string" || !value.trim()) return undefined; + try { + const parsed = new URL(value); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return undefined; + } +} + +function sanitizeMcpCommand(value) { + if (typeof value !== "string" || !value.trim()) return undefined; + const executable = value.trim().split(/\s+/u)[0]; + return executable.split(/[\\/]/u).filter(Boolean).at(-1); +} + +function sanitizeMcpArgs(args, command) { + const values = Array.isArray(args) ? args : []; + const runner = sanitizeMcpCommand(command)?.toLowerCase(); + let packageRetained = false; + return values.map((value, index) => { + const text = String(value); + const previous = String(values[index - 1] ?? ""); + if (/(?:token|secret|password|credential|api[_-]?key|authorization)/iu.test(previous)) return ""; + if (/(?:token|secret|password|credential|api[_-]?key|authorization|bearer)/iu.test(text)) return ""; + if (/^--?[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(text)) return text; + if (/^(?:\$\{?[A-Z0-9_]+\}?|%[A-Z0-9_]+%)$/u.test(text)) return text; + if (/\.(?:c?m?js|ts|py|sh|rb|ps1|cmd|bat)$/iu.test(text)) return path.basename(text); + if (["npx", "bunx", "uvx"].includes(runner) && !packageRetained && /^(?:@[A-Za-z0-9_.-]+\/)?[A-Za-z0-9_.-]+(?:@[A-Za-z0-9_.^~*-]+)?$/u.test(text)) { + packageRetained = true; + return text; + } + return ""; + }); +} + +function sanitizeMcpItem(item) { + const command = sanitizeMcpCommand(item.command); + const args = sanitizeMcpArgs(item.args, command); + return { + ...item, + command, + args, + argCount: args.length, + url: sanitizeMcpUrl(item.url), + }; +} + +export function sanitizeMcpItems(items) { + return items.map(sanitizeMcpItem); +} + export async function countJsonFiles(root) { if (!(await pathExists(root))) { return 0; diff --git a/scripts/agent-customize/providers/claude.mjs b/scripts/agent-customize/providers/claude.mjs index 0741dba..d6cdfc2 100644 --- a/scripts/agent-customize/providers/claude.mjs +++ b/scripts/agent-customize/providers/claude.mjs @@ -16,9 +16,12 @@ import { collectSkillFiles, evidence, normalizePluginDisplayName, + pathInsideRoot, pluginMetadataEvidencePath, + pluginPathValues, readJson, readText, + sanitizeMcpItems, sortByName, titleCase, uniqueAssetsByRealPath, @@ -70,32 +73,6 @@ function normalizeInstallScope(value) { return scope === "project" || scope === "local" || scope === "workspace" ? "project" : "user"; } -function pluginPathValues(value) { - if (typeof value === "string" && value.trim()) return [value.trim()]; - if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()); - return []; -} - -async function pathInsideRoot(root, relativePath) { - if (!root || typeof relativePath !== "string" || !relativePath.trim()) return undefined; - const base = path.resolve(root); - const candidate = path.resolve(base, relativePath); - const lexicalRelative = path.relative(base, candidate); - if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) { - return undefined; - } - if (!(await pathExists(candidate))) return undefined; - const [realBase, realCandidate] = await Promise.all([ - realpath(base).catch(() => base), - realpath(candidate).catch(() => candidate), - ]); - const resolvedRelative = path.relative(realBase, realCandidate); - if (resolvedRelative === ".." || resolvedRelative.startsWith(`..${path.sep}`) || path.isAbsolute(resolvedRelative)) { - return undefined; - } - return candidate; -} - async function pathsReferToSameRoot(left, right) { const [resolvedLeft, resolvedRight] = await Promise.all([ realpath(path.resolve(left)).catch(() => path.resolve(left)), @@ -148,62 +125,6 @@ async function collectMarkdownFromRoots(roots, kind, scope, sourceLabel, rootFor )).flat()); } -function sanitizeMcpUrl(value) { - if (typeof value !== "string" || !value.trim()) return undefined; - try { - const parsed = new URL(value); - parsed.username = ""; - parsed.password = ""; - parsed.search = ""; - parsed.hash = ""; - return parsed.toString(); - } catch { - return undefined; - } -} - -function sanitizeMcpCommand(value) { - if (typeof value !== "string" || !value.trim()) return undefined; - const executable = value.trim().split(/\s+/u)[0]; - return executable.split(/[\\/]/u).filter(Boolean).at(-1); -} - -function sanitizeMcpArgs(args, command) { - const values = Array.isArray(args) ? args : []; - const runner = sanitizeMcpCommand(command)?.toLowerCase(); - let packageRetained = false; - return values.map((value, index) => { - const text = String(value); - const previous = String(values[index - 1] ?? ""); - if (/(?:token|secret|password|credential|api[_-]?key|authorization)/iu.test(previous)) return ""; - if (/(?:token|secret|password|credential|api[_-]?key|authorization|bearer)/iu.test(text)) return ""; - if (/^--?[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(text)) return text; - if (/^(?:\$\{?[A-Z0-9_]+\}?|%[A-Z0-9_]+%)$/u.test(text)) return text; - if (/\.(?:c?m?js|ts|py|sh|rb|ps1|cmd|bat)$/iu.test(text)) return path.basename(text); - if (["npx", "bunx", "uvx"].includes(runner) && !packageRetained && /^(?:@[A-Za-z0-9_.-]+\/)?[A-Za-z0-9_.-]+(?:@[A-Za-z0-9_.^~*-]+)?$/u.test(text)) { - packageRetained = true; - return text; - } - return ""; - }); -} - -function sanitizeMcpItem(item) { - const command = sanitizeMcpCommand(item.command); - const args = sanitizeMcpArgs(item.args, command); - return { - ...item, - command, - args, - argCount: args.length, - url: sanitizeMcpUrl(item.url), - }; -} - -function sanitizeMcpItems(items) { - return items.map(sanitizeMcpItem); -} - async function collectClaudeHooks(files, scope, sourceLabel, rootForEvidence, options = {}) { const items = []; for (const filePath of files) { diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index 8e7f5c9..474ea9d 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -4,6 +4,7 @@ import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; import { collectGrokCustomizeInventory } from "./grok.mjs"; import { collectPiCustomizeInventory } from "./pi.mjs"; +import { collectKimiCustomizeInventory } from "./kimi.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; import { collectQwenCustomizeInventory } from "./qwen.mjs"; import { collectWorkbuddyCustomizeInventory } from "./workbuddy.mjs"; @@ -16,6 +17,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["qwen", collectQwenCustomizeInventory], ["copilot", collectCopilotCustomizeInventory], ["pi", collectPiCustomizeInventory], + ["kimi", collectKimiCustomizeInventory], ["workbuddy", collectWorkbuddyCustomizeInventory], ["grok", collectGrokCustomizeInventory], ]); diff --git a/scripts/agent-customize/providers/kimi.mjs b/scripts/agent-customize/providers/kimi.mjs new file mode 100644 index 0000000..eb7da45 --- /dev/null +++ b/scripts/agent-customize/providers/kimi.mjs @@ -0,0 +1,344 @@ +import os from "node:os"; +import path from "node:path"; + +import { + expandHome, + normalizeWorkspace, + pathExists, + pathStat, + walkFiles, +} from "../../session-analysis/index.mjs"; +import { MANAGE_TABS } from "../constants.mjs"; +import { + agentsMarkdownRuleSource, + buildManageCollections, + collectHooksFromConfig, + collectMarkdownItems, + collectMcpFromValue, + collectMcpItems, + collectRuleSources, + collectSkillFiles, + evidence, + filterItemsInsideRoot, + normalizePluginDisplayName, + pathInsideRoot, + pluginMetadataEvidencePath, + pluginPathValues, + readJson, + readMarkdownName, + sanitizeMcpItems, + sortByName, + titleCase, + uniqueAssetsByRealPath, + withoutExtension, + workspaceSourceLabel, +} from "../core/items.mjs"; + +const KIMI_PLUGIN_MANIFESTS = [["kimi.plugin.json"], [".kimi-plugin", "plugin.json"]]; + +function defaultKimiHome() { + return path.join(os.homedir(), ".kimi-code"); +} + +function claudeMarkdownRuleSource(workspace, sourceLabel) { + return { + type: "file", + filePath: path.join(workspace, "CLAUDE.md"), + scope: "project", + sourceLabel, + rootForEvidence: workspace, + name: "CLAUDE.md", + sourceKind: "claude-md-compat", + precedence: "after-agents-md", + useHeading: true, + }; +} + +async function collectKimiUserPrimitives(kimiHome) { + return { + skills: await collectSkillFiles(path.join(kimiHome, "skills"), "user", "User", kimiHome), + subagents: [], + rules: [], + commands: [], + hooks: [], + mcps: await collectMcpItems(kimiHome, "user", "User", kimiHome), + }; +} + +async function collectKimiWorkspacePrimitives(workspace) { + const sourceLabel = await workspaceSourceLabel(workspace); + const skills = [ + ...(await collectSkillFiles(path.join(workspace, ".kimi-code", "skills"), "project", sourceLabel, workspace)), + ...(await collectSkillFiles(path.join(workspace, ".kimi", "skills"), "project", sourceLabel, workspace)), + ]; + const rules = await collectRuleSources([ + agentsMarkdownRuleSource(workspace, sourceLabel), + claudeMarkdownRuleSource(workspace, sourceLabel), + ]); + return { skills, subagents: [], rules, commands: [], hooks: [], mcps: [] }; +} + +function emptyPrimitives() { + return { skills: [], subagents: [], rules: [], commands: [], hooks: [], mcps: [] }; +} + +async function readKimiInstalledPluginState(kimiHome) { + const indexPath = path.join(kimiHome, "plugins", "installed.json"); + const exists = await pathExists(indexPath); + const index = exists ? await readJson(indexPath) : undefined; + const records = []; + if (index && Array.isArray(index.plugins)) { + for (const record of index.plugins) { + const id = typeof record?.id === "string" ? record.id.trim() : ""; + const root = typeof record?.root === "string" ? record.root.trim() : ""; + if (!id || !root) continue; + records.push({ + id, + root: path.resolve(expandHome(root)), + source: typeof record.source === "string" ? record.source : undefined, + enabled: record.enabled === true, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + originalSource: record.originalSource, + }); + } + } + return { + indexPath, + exists, + parseFailed: exists && index === undefined, + indexVersion: index?.version, + records, + }; +} + +async function readKimiPluginManifest(pluginRoot) { + let parseFailedPath; + for (const segments of KIMI_PLUGIN_MANIFESTS) { + const manifestPath = path.join(pluginRoot, ...segments); + if (!(await pathExists(manifestPath))) continue; + const manifest = await readJson(manifestPath); + if (manifest && typeof manifest === "object" && !Array.isArray(manifest)) { + return { manifest, manifestPath }; + } + // A malformed manifest must not mask a valid fallback candidate. + parseFailedPath ??= manifestPath; + } + return { + manifest: {}, + manifestPath: parseFailedPath ?? path.join(pluginRoot, ...KIMI_PLUGIN_MANIFESTS[0]), + parseFailed: parseFailedPath !== undefined, + }; +} + +async function declaredComponentRoots(pluginRoot, values) { + const roots = []; + for (const value of values) { + const candidate = await pathInsideRoot(pluginRoot, value); + if (candidate && !roots.includes(candidate)) roots.push(candidate); + } + return roots; +} + +async function kimiPluginSkillRoots(pluginRoot, manifest) { + if (Object.hasOwn(manifest, "skills")) { + return declaredComponentRoots(pluginRoot, pluginPathValues(manifest.skills)); + } + return (await pathExists(path.join(pluginRoot, "SKILL.md"))) ? [pluginRoot] : []; +} + +async function kimiPluginAgentRoots(pluginRoot, manifest) { + if (Object.hasOwn(manifest, "agents")) { + return declaredComponentRoots(pluginRoot, pluginPathValues(manifest.agents)); + } + return declaredComponentRoots(pluginRoot, ["agents"]); +} + +async function collectSkillsFromRoots(roots, sourceLabel, pluginRoot) { + return uniqueAssetsByRealPath((await Promise.all( + roots.map((root) => collectSkillFiles(root, "plugin", sourceLabel, pluginRoot)), + )).flat()); +} + +async function collectKimiPluginCommands(pluginRoot, manifest, sourceLabel) { + const items = []; + for (const declaredPath of pluginPathValues(manifest.commands)) { + const candidate = await pathInsideRoot(pluginRoot, declaredPath); + if (!candidate) continue; + const stats = await pathStat(candidate); + const isDirectory = stats?.isDirectory() === true; + const files = isDirectory + ? await walkFiles(candidate, { + maxDepth: 4, + limit: 2000, + match: (filePath) => path.extname(filePath) === ".md", + }) + : stats?.isFile() && path.extname(candidate) === ".md" + ? [candidate] + : []; + for (const filePath of files) { + const fallback = isDirectory + ? path.relative(candidate, filePath).replace(/\.md$/iu, "").split(path.sep).join("/") + : withoutExtension(filePath); + const metadata = await readMarkdownName(filePath, fallback); + items.push({ + id: `plugin:command:${filePath}`, + kind: "command", + scope: "plugin", + sourceLabel, + filePath, + ...metadata, + evidence: evidence(filePath, pluginRoot), + }); + } + } + return (await uniqueAssetsByRealPath(items)).sort(sortByName); +} + +function kimiPluginHookConfig(hooks) { + const grouped = {}; + for (const hook of Array.isArray(hooks) ? hooks : []) { + if (!hook || typeof hook !== "object") continue; + const event = typeof hook.event === "string" && hook.event.trim() ? hook.event.trim() : "unknown"; + (grouped[event] ??= []).push({ + matcher: typeof hook.matcher === "string" ? hook.matcher : undefined, + hooks: [{ type: "command", command: hook.command, timeout: hook.timeout }], + }); + } + return { hooks: grouped }; +} + +function collectKimiPluginHooks(manifest, manifestPath, sourceLabel, pluginRoot) { + if (!Array.isArray(manifest.hooks)) return []; + return collectHooksFromConfig( + kimiPluginHookConfig(manifest.hooks), + { filePath: manifestPath, scope: "plugin", sourceLabel, rootForEvidence: pluginRoot }, + { timeoutUnit: "seconds" }, + ); +} + +function collectKimiPluginMcps(manifest, manifestPath, sourceLabel, pluginRoot) { + const declared = manifest.mcpServers; + if (!declared || typeof declared !== "object" || Array.isArray(declared)) return []; + const servers = declared.mcpServers ?? declared; + return sanitizeMcpItems(collectMcpFromValue(servers, "plugin", sourceLabel, manifestPath, pluginRoot)); +} + +async function collectKimiPlugin(record) { + const pluginRoot = record.root; + const { manifest, manifestPath } = await readKimiPluginManifest(pluginRoot); + const name = typeof manifest.name === "string" && manifest.name.trim() ? manifest.name.trim() : record.id; + const displayName = normalizePluginDisplayName( + typeof manifest.interface?.displayName === "string" && manifest.interface.displayName.trim() + ? manifest.interface.displayName + : titleCase(name), + name, + ); + const plugin = { + id: record.id, + kimiPluginId: record.id, + rootPath: pluginRoot, + scope: "plugin", + sourceLabel: displayName, + name, + displayName, + description: typeof manifest.description === "string" ? manifest.description : "", + version: manifest.version, + enabled: record.enabled, + installedAt: record.installedAt, + updatedAt: record.updatedAt, + installSource: "user", + installSources: ["user"], + source: record.source, + originalSource: record.originalSource, + // systemPrompt injection is plugin metadata only; it is never merged into rules. + systemPrompt: typeof manifest.systemPrompt === "string" ? manifest.systemPrompt : undefined, + systemPromptPath: typeof manifest.systemPromptPath === "string" + ? await pathInsideRoot(pluginRoot, manifest.systemPromptPath) + : undefined, + sessionStartSkill: typeof manifest.sessionStart?.skill === "string" ? manifest.sessionStart.skill : undefined, + evidence: evidence(await pluginMetadataEvidencePath(pluginRoot, KIMI_PLUGIN_MANIFESTS), pluginRoot), + skills: [], + subagents: [], + rules: [], + commands: [], + hooks: [], + mcpServers: [], + }; + if (!plugin.enabled) return plugin; + const [skillRoots, agentRoots] = await Promise.all([ + kimiPluginSkillRoots(pluginRoot, manifest), + kimiPluginAgentRoots(pluginRoot, manifest), + ]); + // Collection follows symlinks, so a symlink inside the plugin root could + // otherwise pull files from outside it into the inventory; drop any item + // whose realpath escapes the plugin root. + plugin.skills = await filterItemsInsideRoot( + await collectSkillsFromRoots(skillRoots, displayName, pluginRoot), + pluginRoot, + ); + plugin.subagents = await filterItemsInsideRoot( + await uniqueAssetsByRealPath((await Promise.all( + agentRoots.map((root) => collectMarkdownItems(root, "subagent", "plugin", displayName, pluginRoot)), + )).flat()), + pluginRoot, + ); + plugin.commands = await filterItemsInsideRoot( + await collectKimiPluginCommands(pluginRoot, manifest, displayName), + pluginRoot, + ); + plugin.hooks = collectKimiPluginHooks(manifest, manifestPath, displayName, pluginRoot); + plugin.mcpServers = collectKimiPluginMcps(manifest, manifestPath, displayName, pluginRoot); + return plugin; +} + +async function collectKimiPlugins(kimiHome) { + const state = await readKimiInstalledPluginState(kimiHome); + const plugins = []; + for (const record of state.records) plugins.push(await collectKimiPlugin(record)); + return { state, plugins: plugins.sort(sortByName) }; +} + +export async function collectKimiCustomizeInventory(options = {}) { + const kimiHome = path.resolve(expandHome(options.kimiHome ?? options["kimi-home"] ?? defaultKimiHome())); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const includeUserHome = options.includeUserHome !== false; + const [pluginState, user, project] = await Promise.all([ + includeUserHome ? collectKimiPlugins(kimiHome) : Promise.resolve(undefined), + includeUserHome ? collectKimiUserPrimitives(kimiHome) : emptyPrimitives(), + collectKimiWorkspacePrimitives(workspace), + ]); + const plugins = pluginState?.plugins ?? []; + const configPath = path.join(kimiHome, "config.toml"); + return { + generatedAt: new Date().toISOString(), + provider: "kimi", + kimiHome, + workspace, + tabs: MANAGE_TABS, + plugins, + manage: buildManageCollections(plugins, user, project), + diagnostics: { + configTomlExists: await pathExists(configPath), + configTomlNotParsed: "config.toml holds model/provider settings, not customizable assets", + projectSkillRootsProbed: [ + path.join(workspace, ".kimi-code", "skills"), + path.join(workspace, ".kimi", "skills"), + ], + installedPluginIndexPath: path.join(kimiHome, "plugins", "installed.json"), + ...(pluginState + ? { + installedPluginIndexExists: pluginState.state.exists, + installedPluginIndexParseFailed: pluginState.state.parseFailed, + installedPluginIndexVersion: pluginState.state.indexVersion, + installedPluginRecordCount: pluginState.state.records.length, + enabledPluginCount: plugins.filter((plugin) => plugin.enabled).length, + } + : { pluginCollectionSkipped: "include-user-home-disabled" }), + }, + unsupported: [ + "plugin systemPrompt/systemPromptPath injection (recorded as plugin metadata only, not merged into rules)", + "memory (Kimi Code has no memory equivalent)", + ], + }; +} diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index 0d29bf2..e3a730c 100644 --- a/scripts/agent-lint/cli.mjs +++ b/scripts/agent-lint/cli.mjs @@ -133,6 +133,15 @@ function parseArgs(argv) { options.cursorHome = arg.slice("--cursor-home=".length); continue; } + if (arg === "--kimi-home") { + options.kimiHome = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--kimi-home=")) { + options.kimiHome = arg.slice("--kimi-home=".length); + continue; + } if (arg === "--max-reference-depth") { options.maxReferenceDepth = argv[index + 1]; index += 1; @@ -167,7 +176,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/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 831ef18..3149598 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, WorkBuddy, and Grok session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, Kimi, WorkBuddy, and Grok session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index 036e04b..5407614 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -16,7 +16,7 @@ export const MAX_BASELINE_FINDINGS = 16; export const MAX_BASELINE_OWNER_ROUTES = 16; const MAX_OWNER_ROUTE_STAT_CONCURRENCY = 32; -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"]); const SEVERITY_RANK = Object.freeze({ error: 0, warning: 1, advisory: 2 }); const OWNER_KIND_RANK = Object.freeze({ rules: 0, @@ -301,7 +301,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, grok.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, kimi, workbuddy, grok.`); } const workspace = normalizeWorkspace(options.workspace ?? "."); const includeUserHome = parseBooleanFlag(options.includeUserHome ?? options["include-user-home"] ?? false); @@ -432,7 +432,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|grok] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot|pi|kimi|workbuddy|grok] [options] Collect one compact, read-only AI evidence envelope from a shared asset snapshot. @@ -442,6 +442,7 @@ Options: --include-memories Include authorized Memory title metadata (default: selected Qoder project) --claude-home Claude config root override --claude-state Claude state-file override + --kimi-home Kimi Code data root override --language Integrity finding language (default: en) --format Output format (default: json) --json Emit JSON diff --git a/scripts/coding-agent-practices/asset-integrity.mjs b/scripts/coding-agent-practices/asset-integrity.mjs index 51e09b7..67cbe2b 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|grok] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot|pi|kimi|workbuddy|grok] [options] Run a read-only metadata integrity review for Memory titles, enabled Plugins, and Hooks. Memory bodies are never read. @@ -352,6 +352,7 @@ Options: --claude-home Claude config root override --claude-state Claude state-file override --cursor-home Cursor home override + --kimi-home Kimi Code data root override --shared-cache Qoder shared-cache override --include-memories Include generated Memory title metadata --include-user-home Include user/global asset metadata @@ -369,7 +370,7 @@ 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", "grok"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, workbuddy, grok.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, kimi, 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 7d71dbd..677876e 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", "grok"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"].includes(scope.platform)) { return []; } return [ @@ -455,6 +455,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { qwenHome: options.qwenHome ?? options["qwen-home"], copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], + kimiHome: options.kimiHome ?? options["kimi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], grokHome: options.grokHome ?? options["grok-home"], }; @@ -551,7 +552,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 ?? inventory.grokHome; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome ?? inventory.piHome ?? inventory.kimiHome ?? inventory.workbuddyHome ?? inventory.grokHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -673,6 +674,7 @@ export async function collectProviderInventory(options = {}) { qwenHome: scope.qwenHome, copilotHome: scope.copilotHome, piHome: scope.piHome, + kimiHome: scope.kimiHome, workbuddyHome: scope.workbuddyHome, grokHome: scope.grokHome, includeUserHome: scope.includeUserHome, @@ -928,12 +930,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|grok] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot|pi|kimi|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 @@ -941,6 +943,7 @@ Options: --include-memories Include Qoder or Codex memory metadata --claude-home Claude config root override --claude-state Claude state-file override + --kimi-home Kimi Code data root override -h, --help Print this help `; @@ -955,9 +958,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", "grok"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi, workbuddy, grok.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi, kimi, 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 2d73609..a1db9c6 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", "grok"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "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 4052fb9..e2ad39c 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, workbuddy, or grok (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, kimi, 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 @@ -23,6 +23,7 @@ Options: --include-memories Include authorized Memory title metadata --claude-home Claude config root override --claude-state Claude state-file override + --kimi-home Kimi Code data root 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 @@ -35,7 +36,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", "grok-home", "claude-state", "help", "h", + "cursor-home", "qwen-home", "copilot-home", "pi-home", "kimi-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 61abb71..5a77fe0 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", "grok"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "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 dad314e..c10a528 100644 --- a/scripts/harness-analysis/render-report.mjs +++ b/scripts/harness-analysis/render-report.mjs @@ -45,7 +45,7 @@ function filesystemPathIdentity(value) { // test/harness-report-render-cli.test.mjs guards it against the session // platform registry in scripts/session-analysis/analyzer.mjs. export const RENDER_REPORT_PLATFORMS = Object.freeze([ - "qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy", "grok", + "qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok", ]); // Each Canvas mode owns its own analyzer companion filename so the two routes diff --git a/scripts/harness-analysis/report-quality.mjs b/scripts/harness-analysis/report-quality.mjs index ebfece2..8c26a5f 100644 --- a/scripts/harness-analysis/report-quality.mjs +++ b/scripts/harness-analysis/report-quality.mjs @@ -142,7 +142,7 @@ const AI_PRACTICE_SCOPE_NEGATIVE_RE = /^\s*(?:(?:not in scope|out of scope|not i const AI_PRACTICE_SECTION_RE = /^(#{2,4})\s+(?:AI Agent Practices|Coding Agent Practices|AI Agent 实践|智能体实践|编码代理实践)(?:\s|$)/im; const AI_PRACTICE_LABEL_RE = /^\s*\*\*(?:AI Agent Practices|Coding Agent Practices|AI Agent 实践|智能体实践|编码代理实践)\s*[::]?\*\*\s*$/im; const AI_PRACTICE_SURFACE_RE = /\b(?:Rules|Hooks|Skills|Custom Agents|MCP|Plugins|Session Insights|Sessions|DESIGN\.md|Design Tokens?|Design Contract|design-token contract)\b|规则|技能|自定义\s*(?:Agent|智能体)|插件|会话洞察|会话|设计(?:令牌|契约)/i; -const AI_PRACTICE_SESSION_SCOPE_RE = /(?:\.qoder|\.codex|\.claude|\.cursor|\.qwen|\.copilot|\.pi\b|\.workbuddy|qoder|codex|claude|cursor|qwen|copilot|\bpi\b|workbuddy|session-analysis|session sources|session evidence|会话分析|会话证据)/i; +const AI_PRACTICE_SESSION_SCOPE_RE = /(?:\.qoder|\.codex|\.claude|\.cursor|\.qwen|\.copilot|\.pi\b|\.workbuddy|\.kimi-code|\.kimi|qoder|codex|claude|cursor|qwen|copilot|\bpi\b|workbuddy|kimi|session-analysis|session sources|session evidence|会话分析|会话证据)/i; const AI_READINESS_DIMENSION_RE = /\bAI Readiness\b|\bAI Agent Readiness\b|AI\s*(?:就绪度|就绪|准备度)/i; const SESSION_SOURCES_RE = /session-analysis\.mjs\s+sources/i; const SESSION_BOUNDARY_RE = /session-analysis\.mjs\s+(?:sources\s+and\s+)?facets|no enabled roots|no enabled session|no sessions|no session(?: analysis)? evidence|no agent session logs|no execution history|no proof of agent workflow|no-session boundary|source probe failed|session-analysis (?:was )?not run|session sources.*none|qoder\/codex session sources.*none|没有启用.*(?:root|session|根|会话)|没有.*会话|源探测失败/i; diff --git a/scripts/harness-analysis/report-run.mjs b/scripts/harness-analysis/report-run.mjs index 47431c8..28416e8 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, workbuddy, or grok (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, kimi, 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", "grok"]; +const REPORT_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "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", "grok-home", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", "pi-home", "kimi-home", "workbuddy-home", "grok-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); @@ -170,6 +170,7 @@ export async function analyzeHarnessEvidence(options = {}) { qwenHome: options["qwen-home"], copilotHome: options["copilot-home"], piHome: options["pi-home"], + kimiHome: options["kimi-home"], sessionPopulation: options.sessionPopulation, workbuddyHome: options["workbuddy-home"], grokHome: options["grok-home"], diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index 77aa8c0..6f4d59c 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -1510,7 +1510,7 @@ function checkupReportFindings(source) { function usageOutcomeReviewLead(source, locale) { const usage = source?.sessionEvents?.usageEfficiency; if (!isObject(usage) || !isObject(usage.selection) || !isObject(usage.longSessions) || !isObject(usage.outcomeReview)) return null; - const platform = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "workbuddy"].includes(source?.manifest?.scope?.platform) + const platform = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy"].includes(source?.manifest?.scope?.platform) ? source.manifest.scope.platform : "qoder"; const activeCount = Number(usage?.longSessions?.activeCount ?? 0); diff --git a/scripts/harness-analysis/task-loop-source.mjs b/scripts/harness-analysis/task-loop-source.mjs index c5011dc..f9bb58a 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -76,13 +76,13 @@ const REQUIRED_SOFTWARE_FLUENCY_CAPABILITIES = Object.freeze([ const HELP = `Usage: node scripts/harness-analysis/task-loop-source.mjs --workspace --source [options] Create a conservative Agent Work Loop report-source candidate from normalized -Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or WorkBuddy sessions. It retains privacy-safe episode, change, validation, +Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, Kimi, WorkBuddy, or Grok sessions. It retains privacy-safe episode, change, validation, repair-candidate, and explicit host-decision identities. Task understanding, 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", "grok"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"].includes(provider); const common = { workspace: options.workspace, provider, @@ -856,6 +856,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { qwenHome: options.qwenHome ?? options["qwen-home"], copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], + kimiHome: options.kimiHome ?? options["kimi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], grokHome: options.grokHome ?? options["grok-home"], topology: options.topology, @@ -968,6 +969,16 @@ export function collectTaskLoopPracticeInventory(options = {}, platform = option piHome: options.piHome ?? options["pi-home"], }); } + if (platform === "kimi") { + return collectProviderInventory({ + platform, + workspace: options.workspace, + includeUserHome: includeGlobalCapabilities, + includeGlobalHooks: true, + includeMemories: false, + kimiHome: options.kimiHome ?? options["kimi-home"], + }); + } if (platform === "workbuddy") { return collectProviderInventory({ platform, @@ -1103,6 +1114,7 @@ export async function createTaskLoopSourceFromSessions(options = {}) { qwenHome: options.qwenHome ?? options["qwen-home"], copilotHome: options.copilotHome ?? options["copilot-home"], piHome: options.piHome ?? options["pi-home"], + kimiHome: options.kimiHome ?? options["kimi-home"], workbuddyHome: options.workbuddyHome ?? options["workbuddy-home"], grokHome: options.grokHome ?? options["grok-home"], includeGlobalCapabilities: options.includeGlobalCapabilities diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 35a4b8a..7dd5ccc 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -56,6 +56,7 @@ function verifyReleaseVersionAlignment() { [".github/plugin/plugin.json", readJson(".github/plugin/plugin.json").version], [".github/plugin/marketplace.json", readJson(".github/plugin/marketplace.json").plugins?.[0]?.version], ["qwen-extension.json", readJson("qwen-extension.json").version], + [".kimi-plugin/plugin.json", readJson(".kimi-plugin/plugin.json").version], ]; for (const [source, version] of versions) { if (version !== packageVersion) { @@ -142,6 +143,7 @@ const required = [ "package/.github/plugin/plugin.json", "package/.github/plugin/marketplace.json", "package/.qoder-plugin/plugin.json", + "package/.kimi-plugin/plugin.json", "package/qwen-extension.json", "package/prompts/better-harness.md", "package/case-studies/factory/model/factory-readiness.md", @@ -353,6 +355,7 @@ const forbiddenBundlePrefixes = [ ".codex-plugin/", ".cursor-plugin/", ".github/plugin/", + ".kimi-plugin/", "qwen-extension.json", "prompts/", "test/", diff --git a/scripts/session-analysis/analyzer.mjs b/scripts/session-analysis/analyzer.mjs index f2ff2b3..6e116bc 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, WorkBuddy, or Grok session evidence. The +Inspect local Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, Kimi, 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) @@ -45,6 +45,7 @@ Options: --qwen-home Qwen Code data root (default: ~/.qwen) --copilot-home Copilot CLI data root (default: ~/.copilot) --pi-home Pi agent data root (default: ~/.pi/agent) + --kimi-home Kimi Code data root (default: ~/.kimi-code) --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 @@ -239,6 +240,7 @@ const PLATFORM_MODULES = Object.freeze({ qwen: { specifier: "./platforms/qwen.mjs", analyzer: "QwenSessionAnalyzer" }, copilot: { specifier: "./platforms/copilot.mjs", analyzer: "CopilotSessionAnalyzer" }, pi: { specifier: "./platforms/pi.mjs", analyzer: "PiSessionAnalyzer" }, + kimi: { specifier: "./platforms/kimi.mjs", analyzer: "KimiSessionAnalyzer" }, workbuddy: { specifier: "./platforms/workbuddy.mjs", analyzer: "WorkbuddySessionAnalyzer" }, grok: { specifier: "./platforms/grok.mjs", analyzer: "GrokSessionAnalyzer" }, }); @@ -309,14 +311,14 @@ 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, ...eventOptions, ...claudeOptions, "", - "Options: --workbuddy-home overrides the WorkBuddy data root (default: ~/.workbuddy); --grok-home overrides the Grok data root (default: ~/.grok or $GROK_HOME).", + "Options: --kimi-home overrides the Kimi Code data root (default: ~/.kimi-code); --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/scripts/session-analysis/lifecycle-demand-signals.mjs b/scripts/session-analysis/lifecycle-demand-signals.mjs index 8c4919a..59fe616 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", "grok"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"].includes(host) ? host : "unknown"; } function safeEvidenceToken(value, fallback) { diff --git a/scripts/session-analysis/platforms/kimi.mjs b/scripts/session-analysis/platforms/kimi.mjs new file mode 100644 index 0000000..2756296 --- /dev/null +++ b/scripts/session-analysis/platforms/kimi.mjs @@ -0,0 +1,556 @@ +#!/usr/bin/env node + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SessionAnalyzer } from "../../session-analysis.mjs"; +import { parseArgs, parseBooleanFlag } from "../cli.mjs"; +import { forEachJsonLine, pathExists, pathStat, walkFiles } from "../fs.mjs"; +import { expandHome, normalizeWorkspace } from "../paths.mjs"; +import { + emitProviderResult, + runProviderAnalysis, + runProviderCommand, +} from "../provider-runner.mjs"; +import { parseResultFacts } from "../result-facts.mjs"; +import { mergeTimeRange, normalizeCliDate, normalizeTimestamp, timestampMillis, withinTimeRange } from "../time.mjs"; + +const SESSION_DIR_RE = /^(?:ses|session)_[0-9a-f-]+$/iu; +const WORKSPACE_DIR_RE = /^wd_.+_[0-9a-f]+$/iu; +const CONTROL_RECORD_TYPES = new Set([ + "config.update", + "tools.set_active_tools", + "tools.update_store", + "llm.tools_snapshot", + "mcp.tools_discovered", +]); + +function comparePath(value) { + if (!value) return ""; + let resolved = path.resolve(expandHome(String(value))).replace(/\\/gu, "/"); + if (process.platform === "win32") resolved = resolved.toLowerCase(); + return resolved.replace(/\/+$/u, ""); +} + +function isWorkspaceMatch(candidate, workspace) { + if (!candidate) return false; + const resolved = comparePath(candidate); + const target = comparePath(workspace); + return resolved === target || resolved.startsWith(`${target}/`); +} + +async function readJson(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch { + return undefined; + } +} + +async function listDirectories(root, match) { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory() && (!match || match.test(entry.name))) + .map((entry) => path.join(root, entry.name)) + .sort((left, right) => left.localeCompare(right)); +} + +async function loadWorkspacesIndex(kimiHome) { + const filePath = path.join(kimiHome, "workspaces.json"); + const data = await readJson(filePath); + const roots = new Map(); + for (const [workspaceId, record] of Object.entries(data?.workspaces ?? {})) { + if (typeof record?.root === "string" && record.root.trim()) { + roots.set(workspaceId, record.root); + } + } + return { filePath, roots, exists: roots.size > 0 || await pathExists(filePath) }; +} + +async function loadSessionIndex(kimiHome) { + const filePath = path.join(kimiHome, "session_index.jsonl"); + const workDirs = new Map(); + if (await pathExists(filePath)) { + await forEachJsonLine(filePath, (raw) => { + if (typeof raw?.sessionDir === "string" && typeof raw?.workDir === "string") { + workDirs.set(comparePath(raw.sessionDir), raw.workDir); + } + }); + } + return { filePath, workDirs, exists: workDirs.size > 0 || await pathExists(filePath) }; +} + +function inferTimestamp(raw) { + return normalizeTimestamp(raw?.time ?? raw?.timestamp ?? raw?.created_at ?? null); +} + +function textFromParts(parts) { + if (typeof parts === "string") return parts.trim(); + if (!Array.isArray(parts)) return ""; + return parts + .filter((part) => typeof part === "string" || part?.type === "text") + .map((part) => (typeof part === "string" ? part : part?.text ?? "")) + .filter(Boolean) + .join("\n") + .trim(); +} + +function inferFilePath(toolName, input = {}) { + if (!/(?:read|edit|write|file|notebook|glob)/iu.test(String(toolName ?? ""))) return null; + return input.file_path ?? input.filePath ?? input.path ?? null; +} + +function inferCommandText(toolName, input = {}) { + if (!/(?:bash|shell|exec|terminal)/iu.test(String(toolName ?? ""))) return null; + return input.command ?? input.cmd ?? null; +} + +function evidenceRef(sourceRef, type, seq = null) { + return { + kind: sourceRef.kind, + path: sourceRef.path, + line: sourceRef.line ?? null, + seq, + type, + }; +} + +function toolCallEvent(base, sourceRef, name, toolCallId, args, options, seq = null) { + const toolName = name ?? "unknown-tool"; + const input = args && typeof args === "object" ? args : {}; + const event = { + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName, + toolInvocationId: toolCallId ?? null, + evidenceRef: evidenceRef(sourceRef, "tool.call", seq), + summary: `${toolName} request`, + }; + const commandText = inferCommandText(toolName, input); + const filePath = inferFilePath(toolName, input); + if (options.includeCommandText && commandText) event.commandText = commandText; + if (filePath) event.filePath = filePath; + if (toolName === "Skill") { + const skillName = input.skill ?? input.name; + if (skillName) { + event.skillName = String(skillName).split(":").at(-1); + event.skillNames = [event.skillName]; + } + } + return event; +} + +function toolResultEvent(base, sourceRef, toolCallId, result, options) { + const record = result && typeof result === "object" ? result : { output: result }; + const failed = record.isError === true || record.error != null; + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolInvocationId: toolCallId ?? null, + success: !failed, + hasError: failed, + evidenceRef: evidenceRef(sourceRef, "tool.result"), + summary: failed ? "tool result failed" : "tool result", + }; + const output = typeof record.output === "string" ? record.output : JSON.stringify(record.output ?? ""); + const resultFacts = parseResultFacts(output.slice(-8_192)); + if (resultFacts) event.resultFacts = resultFacts; + return event; +} + +function messageEvents(base, sourceRef, role, text, options) { + const type = role === "user" ? "user" : "assistant"; + const event = { + ...base, + type, + category: type, + evidenceRef: evidenceRef(sourceRef, type), + summary: text ? `${type} message (${text.length} chars)` : type, + contentLength: text.length, + }; + if (type === "user") { + event.userPrompt = text.length > 0; + if (options.includeUserText && text) event.userText = text; + } else if (text) { + event.userVisibleAssistantMessage = true; + } + if (options.includeContent && text) event.content = text; + return [event]; +} + +function loopEvents(raw, base, sourceRef, options) { + const loop = raw?.event && typeof raw.event === "object" ? raw.event : {}; + const loopType = loop.type ?? "unknown"; + if (loopType === "tool.call") { + return [toolCallEvent(base, sourceRef, loop.name, loop.toolCallId ?? loop.uuid, loop.args, options)]; + } + if (loopType === "tool.result") { + return [toolResultEvent(base, sourceRef, loop.toolCallId ?? loop.parentUuid, loop.result, options)]; + } + if (loopType === "content.part") { + const part = loop.part && typeof loop.part === "object" ? loop.part : {}; + if (part.type === "text") { + const text = String(part.text ?? "").trim(); + return messageEvents(base, sourceRef, "assistant", text, options); + } + const think = String(part.think ?? part.text ?? ""); + return [{ + ...base, + type: `metadata.${part.type ?? "content.part"}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, "content.part"), + summary: part.type === "think" ? "thinking" : String(part.type ?? "content.part"), + contentLength: think.length, + ...(options.includeContent && think ? { content: think } : {}), + }]; + } + if (loopType === "step.begin" || loopType === "step.end") { + return [{ + ...base, + type: `metadata.${loopType}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, loopType), + summary: loopType === "step.end" && loop.finishReason ? `step end (${loop.finishReason})` : loopType, + }]; + } + return [{ + ...base, + type: `metadata.${loopType}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${loopType}`), + summary: loopType, + }]; +} + +function wireEvents(raw, sourceRef, options) { + const rawType = raw?.type ?? "record"; + const base = { + sessionId: sourceRef.sessionId, + timestamp: inferTimestamp(raw), + sourceKind: sourceRef.kind, + planningScope: "workspace", + isSubagent: sourceRef.agentId ? sourceRef.agentId !== "main" : null, + }; + + if (rawType === "metadata") { + return [{ + ...base, + type: "metadata.wire", + category: "metadata", + evidenceRef: evidenceRef(sourceRef, "metadata.wire"), + summary: `kimi wire protocol ${raw.protocol_version ?? "unknown"}`, + }]; + } + if (rawType === "turn.prompt" || rawType === "turn.steer") { + return messageEvents(base, sourceRef, "user", textFromParts(raw?.input), options); + } + if (rawType === "context.append_message") { + const message = raw?.message && typeof raw.message === "object" ? raw.message : {}; + const role = message.role === "user" ? "user" : message.role === "assistant" ? "assistant" : null; + const events = role ? messageEvents(base, sourceRef, role, textFromParts(message.content), options) : []; + const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : []; + toolCalls.forEach((toolCall, index) => { + if (!toolCall || typeof toolCall !== "object") return; + events.push(toolCallEvent( + base, + sourceRef, + toolCall.name, + toolCall.id ?? toolCall.toolCallId, + toolCall.args ?? toolCall.input, + options, + index, + )); + }); + return events; + } + if (rawType === "context.append_loop_event") { + return loopEvents(raw, base, sourceRef, options); + } + if (rawType === "usage.record") { + const usage = raw?.usage && typeof raw.usage === "object" ? raw.usage : {}; + // Keep partial usage explicit: carry only finite fields the record + // actually observed and never coerce a missing or malformed sibling field + // to zero. Without one finite field there is no usage event at all. + const finite = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined); + const inputOther = finite(usage.inputOther ?? usage.input); + const cacheRead = finite(usage.inputCacheRead); + const cacheCreation = finite(usage.inputCacheCreation); + const output = finite(usage.output); + const modelUsage = {}; + const inputParts = [inputOther, cacheRead, cacheCreation].filter((value) => value !== undefined); + if (inputParts.length > 0) modelUsage.inputTokens = inputParts.reduce((sum, value) => sum + value, 0); + if (output !== undefined) modelUsage.outputTokens = output; + if (cacheRead !== undefined) modelUsage.cacheReadInputTokens = cacheRead; + if (cacheCreation !== undefined) modelUsage.cacheCreationInputTokens = cacheCreation; + if (Object.keys(modelUsage).length === 0) return []; + return [{ + ...base, + type: "model.response.completed", + category: "model", + model: raw?.model ?? null, + modelUsage, + usageFieldsObserved: true, + evidenceRef: evidenceRef(sourceRef, "model.response.completed"), + summary: "Kimi model usage recorded", + }]; + } + if (CONTROL_RECORD_TYPES.has(rawType)) { + return []; + } + if (rawType === "permission.record_approval_result") { + return [{ + ...base, + type: "metadata.permission", + category: "metadata", + evidenceRef: evidenceRef(sourceRef, "metadata.permission"), + summary: `permission ${raw?.result?.decision ?? "recorded"}`, + }]; + } + return [{ + ...base, + type: `metadata.${rawType}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${rawType}`), + summary: rawType, + }]; +} + +function addRef(sessions, sessionId, workspace, ref, title = null) { + if (!sessionId) return; + const session = sessions.get(sessionId) ?? { + sessionId, + workspace, + title, + firstSeen: null, + lastSeen: null, + sourceKinds: new Set(), + sourceRefs: [], + }; + 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) { + return { ...session, sourceKinds: [...session.sourceKinds].sort() }; +} + +function dedupeEvents(events) { + const seen = new Set(); + return events.filter((event) => { + const key = event.toolInvocationId && event.lifecyclePhase + ? `${event.sessionId}:${event.lifecyclePhase}:${event.toolInvocationId}` + : null; + if (!key) return true; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export class KimiSessionAnalyzer extends SessionAnalyzer { + async resolveScope(options = {}) { + const since = normalizeCliDate(options.since, false); + const until = normalizeCliDate(options.until, true); + const workspace = normalizeWorkspace(options.workspace); + const kimiHome = path.resolve(expandHome( + options.home ?? options.kimiHome ?? options["kimi-home"] ?? "~/.kimi-code", + )); + return { + platform: "kimi", + workspace, + home: kimiHome, + 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), + }; + } + + async resolveWorkspaceDirs(scope) { + const sessionsRoot = path.join(scope.home, "sessions"); + const [workspaceIndex, sessionIndex, candidates] = await Promise.all([ + loadWorkspacesIndex(scope.home), + loadSessionIndex(scope.home), + listDirectories(sessionsRoot, WORKSPACE_DIR_RE), + ]); + const fallbackPrefix = `wd_${path.basename(scope.workspace).toLowerCase()}_`; + const dirs = []; + for (const dir of candidates) { + const workspaceId = path.basename(dir); + const indexedRoot = workspaceIndex.roots.get(workspaceId); + if (indexedRoot !== undefined) { + if (isWorkspaceMatch(indexedRoot, scope.workspace)) dirs.push(dir); + continue; + } + const sessionRoot = [...sessionIndex.workDirs.entries()] + .find(([sessionDir]) => comparePath(path.dirname(sessionDir)) === comparePath(dir)); + if (sessionRoot) { + if (isWorkspaceMatch(sessionRoot[1], scope.workspace)) dirs.push(dir); + continue; + } + if (workspaceIndex.roots.size === 0 && sessionIndex.workDirs.size === 0 + && workspaceId.toLowerCase().startsWith(fallbackPrefix)) { + dirs.push(dir); + } + } + return { sessionsRoot, dirs, workspaceIndex, sessionIndex }; + } + + async discoverSourceRoots(scope) { + const resolved = await this.resolveWorkspaceDirs(scope); + scope._workspaceDirs = resolved.dirs; + scope._indexAbsent = !resolved.workspaceIndex.exists && !resolved.sessionIndex.exists; + scope._sessionWorkDirs = resolved.sessionIndex.workDirs; + const roots = [ + { + id: "kimi-sessions", + kind: "kimi-wire-jsonl", + role: "session-transcript", + path: resolved.sessionsRoot, + optional: false, + enabled: true, + workspaceScoped: true, + coverage: "primary", + }, + { + id: "kimi-session-index", + kind: "kimi-session-index-jsonl", + role: "workspace-session-index", + path: resolved.sessionIndex.filePath, + optional: true, + enabled: true, + workspaceScoped: false, + coverage: "optional", + }, + { + id: "kimi-workspaces", + kind: "kimi-workspaces-json", + role: "workspace-index", + path: resolved.workspaceIndex.filePath, + optional: true, + enabled: true, + workspaceScoped: false, + coverage: "optional", + }, + ]; + return Promise.all(roots.map(async (root) => ({ ...root, exists: await pathExists(root.path) }))); + } + + async discoverSessions(scope, _roots) { + const sessions = new Map(); + for (const workspaceDir of scope._workspaceDirs ?? []) { + for (const sessionDir of await listDirectories(workspaceDir, SESSION_DIR_RE)) { + const sessionId = path.basename(sessionDir); + const indexedWorkDir = scope._sessionWorkDirs?.get(comparePath(sessionDir)); + if (indexedWorkDir && !isWorkspaceMatch(indexedWorkDir, scope.workspace)) continue; + const state = await readJson(path.join(sessionDir, "state.json")); + const wireFiles = await walkFiles(path.join(sessionDir, "agents"), { + maxDepth: 2, + limit: 200, + match: (filePath) => path.basename(filePath) === "wire.jsonl", + }); + if (wireFiles.length === 0) continue; + let firstSeen = normalizeTimestamp(state?.createdAt) ?? null; + let lastSeen = normalizeTimestamp(state?.updatedAt) ?? null; + if (!firstSeen && !lastSeen) { + const mtimes = await Promise.all(wireFiles.map(async (filePath) => (await pathStat(filePath))?.mtimeMs ?? null)); + const newest = mtimes.filter((value) => value !== null).sort((left, right) => right - left)[0] ?? null; + lastSeen = newest ? normalizeTimestamp(newest) : null; + if (!withinTimeRange(lastSeen, scope)) continue; + } else if (!withinTimeRange(lastSeen ?? firstSeen, scope)) { + continue; + } + for (const wirePath of wireFiles) { + const agentId = path.basename(path.dirname(wirePath)); + addRef(sessions, sessionId, scope.workspace, { + kind: "kimi-wire-jsonl", + role: agentId === "main" ? "session-transcript" : "subagent-transcript", + agentId, + path: wirePath, + firstSeen, + lastSeen, + }, typeof state?.title === "string" ? state.title.slice(0, 200) : null); + } + } + } + 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 = {}) { + return wireEvents(raw, sourceRef, options); + } + + async readSession(session, scope, options = {}) { + const events = []; + for (const ref of session.sourceRefs ?? []) { + if (!ref.path.endsWith(".jsonl")) continue; + await forEachJsonLine(ref.path, (raw, line) => { + for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { + if (withinTimeRange(event.timestamp, scope)) events.push(event); + } + }); + } + return dedupeEvents(events).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)); + } + + async analysisWarnings(scope, roots, sessions) { + const warnings = []; + if (scope._indexAbsent) { + warnings.push({ + code: "kimi-workspace-index-absent", + message: "Neither workspaces.json nor session_index.jsonl is available; workspace matching fell back to wd__* directory prefixes.", + }); + } + const sessionsRoot = roots.find((root) => root.kind === "kimi-wire-jsonl"); + if (sessionsRoot?.exists && sessions.length === 0) { + warnings.push({ + code: "kimi-no-workspace-sessions", + message: "No Kimi Code sessions matched this workspace in the selected time window.", + }); + } + return warnings; + } + + async analyze(options = {}) { + return runProviderAnalysis(this, options, { platform: "kimi", adapterVersion: "kimi-v1" }); + } +} + +export async function main(argv = process.argv.slice(2)) { + const { command = "sessions", options } = parseArgs(argv); + const analyzer = new KimiSessionAnalyzer(); + const result = await runProviderCommand(analyzer, command, options); + await emitProviderResult({ provider: "Kimi", 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(`kimi 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 4bdb859..3ecf911 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 341abf1..642c239 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 8630db7..daa46dd 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, WorkBuddy, or Grok, 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, Kimi Code, 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-asset-baseline.test.mjs b/test/agent-asset-baseline.test.mjs index 0a50749..13f277a 100644 --- a/test/agent-asset-baseline.test.mjs +++ b/test/agent-asset-baseline.test.mjs @@ -565,6 +565,37 @@ test("Pi asset baseline completes from a native project fixture", async () => { } }); +test("Kimi asset baseline completes from a native project fixture", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-asset-baseline-kimi-")); + const workspace = path.join(root, "project"); + const kimiHome = path.join(root, ".kimi-home"); + try { + await mkdir(kimiHome, { recursive: true }); + await mkdir(path.join(workspace, ".kimi-code", "skills", "review"), { recursive: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "# Kimi project\n\nRun npm test.\n"); + await writeFile( + path.join(workspace, ".kimi-code", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review a bounded Kimi project change.\n---\n", + ); + + const result = await collectAssetBaseline({ + provider: "kimi", + workspace, + kimiHome, + includeUserHome: false, + }); + + assert.equal(result.status, "complete"); + assert.equal(result.scope.provider, "kimi"); + assert.equal(result.envelopes.inventory.status, "available"); + assert.equal(result.envelopes.lint.data.assetInventory.summary.skills, 1); + assert.equal(result.envelopes.inventory.data.ownerRoutes.items.some((item) => + item.kind === "skills" && item.name === "review"), true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("WorkBuddy asset baseline completes from a native project fixture", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-asset-baseline-workbuddy-")); const workspace = path.join(root, "project"); diff --git a/test/agent-customize-architecture.test.mjs b/test/agent-customize-architecture.test.mjs index 2f3fe1e..70f5e59 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -24,6 +24,7 @@ test("agent-customize inventory keeps host collectors behind provider modules", "scripts/agent-customize/providers/claude.mjs", "scripts/agent-customize/providers/qwen.mjs", "scripts/agent-customize/providers/copilot.mjs", + "scripts/agent-customize/providers/kimi.mjs", "scripts/agent-customize/providers/index.mjs", ]) { await assert.doesNotReject(() => readRepoFile(relativePath), `${relativePath} should exist`); @@ -40,6 +41,7 @@ test("agent-customize inventory keeps host collectors behind provider modules", assert.match(providerIndex, /claude/u); assert.match(providerIndex, /qwen/u); assert.match(providerIndex, /copilot/u); + assert.match(providerIndex, /kimi/u); }); test("host architecture docs keep matrix, providers, and thin shells separate", async () => { @@ -54,7 +56,7 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(adapterReadme, /# Host Adapter Matrix/u); assert.match(adapterReadme, /`docs\/adapters\/qoder\.md`/u); assert.match(adapterReadme, /Codex \| Analysis-capable source-local host \| `\.codex-plugin\/`/u); - assert.match(adapterReadme, /npm package includes six filesystem metadata\s+roots for Qoder, Claude Code, Codex, Cursor, Qwen, and Copilot, plus Pi install\s+metadata in the existing `package\.json`/u); + assert.match(adapterReadme, /npm package includes seven filesystem metadata\s+roots for Qoder, Claude Code, Codex, Cursor, Qwen, Copilot, and Kimi Code,\s+plus Pi install\s+metadata in the existing `package\.json`/u); assert.match(adapterReadme, /generated\s+Qoder runtime bundle\s+includes only the Qoder shell/u); assert.match(adapterReadme, /Cursor \| Canvas-capable source-local host[^\n]+platforms\/cursor\.mjs/u); assert.doesNotMatch(adapterReadme, /Cursor has no session-evidence adapter/u); @@ -72,12 +74,12 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(directoryAdr, /scripts\/packaging\/` owns source-local[\s\S]*excluded from public package\/runtime/u); assert.match(architecture, /The Codex shell\s+owns local install\/discovery metadata only/u); - assert.match(architecture, /public npm\s+package ships all six plugin metadata roots[\s\S]*Qoder runtime bundle\s+includes only `\.qoder-plugin\/`/u); + assert.match(architecture, /public npm\s+package ships all seven plugin metadata roots[\s\S]*Qoder runtime bundle\s+includes only `\.qoder-plugin\/`/u); assert.match(architecture, /do not create a generic detector or signal umbrella/u); assert.match(community, /`docs\/adapters\/README\.md` matrix row/u); - assert.match(community, /Public npm package includes all six current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); + assert.match(community, /Public npm package includes all seven current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); assert.match(community, /owning `models\/\.md`, `scripts\/\/`, or `skills\/\/references\/`/u); - assert.match(glossary, /public npm package ships all six current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); + assert.match(glossary, /public npm package ships all seven current metadata roots[\s\S]*Qoder runtime bundle includes only `\.qoder-plugin\/`/u); assert.match(glossary, /Start with \[model routing\]\(\.\.\/models\/routing\.md\)/u); assert.match(adapterReadme, /Claude Code\s+\|/u); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 0f1bd73..bccf195 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -12,6 +12,7 @@ import { tabAvailableForScope, } from "../scripts/agent-customize/index.mjs"; import { pluginMetadataEvidencePath } from "../scripts/agent-customize/core/items.mjs"; +import { collectKimiCustomizeInventory } from "../scripts/agent-customize/providers/kimi.mjs"; import { qoderWorkspaceSlugs } from "../scripts/agent-customize/providers/qoder.mjs"; import { collectProviderInventory as collectPracticeInventory } from "../scripts/coding-agent-practices/inventory.mjs"; @@ -1592,6 +1593,290 @@ test("Claude public configured-asset surfaces exclude disabled Plugin children", } }); +test("collectKimiCustomizeInventory collects scoped skills, rules, and MCPs with includeUserHome control", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-kimi-")); + const kimiHome = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "kimi-project"); + + try { + await writeText( + path.join(kimiHome, "skills", "user-skill", "SKILL.md"), + "---\nname: user-skill\ndescription: User-scoped Kimi skill.\n---\n", + ); + await writeJson(path.join(kimiHome, "mcp.json"), { + mcpServers: { userMcp: { command: "npx", args: ["-y", "@user/mcp"] } }, + }); + await writeText( + path.join(workspace, ".kimi", "skills", "project-kimi-skill", "SKILL.md"), + "---\nname: project-kimi-skill\ndescription: Project skill from .kimi.\n---\n", + ); + await writeText( + path.join(workspace, ".kimi-code", "skills", "project-kimi-code-skill", "SKILL.md"), + "---\nname: project-kimi-code-skill\ndescription: Project skill from .kimi-code.\n---\n", + ); + await writeText(path.join(workspace, "AGENTS.md"), "# Project Agent Rules\n"); + + const inventory = await collectKimiCustomizeInventory({ kimiHome, workspace }); + + assert.equal(inventory.provider, "kimi"); + assert.equal(inventory.kimiHome, path.resolve(kimiHome)); + assert.equal(inventory.workspace, path.resolve(workspace)); + assert.deepEqual(inventory.plugins, []); + + assert.deepEqual( + inventory.manage.skills + .filter((item) => item.scope === "user") + .map((item) => `${item.name}:${item.sourceLabel}`), + ["user-skill:User"], + ); + assert.deepEqual( + inventory.manage.skills + .filter((item) => item.scope === "project") + .map((item) => `${item.name}:${item.sourceLabel}`), + ["project-kimi-code-skill:kimi-project", "project-kimi-skill:kimi-project"], + ); + assert.deepEqual( + inventory.manage.rules.map((item) => `${item.scope}:${item.name}:${item.sourceKind}`), + ["project:AGENTS.md:agents-md-compat"], + ); + assert.deepEqual( + inventory.manage.mcps.map((item) => `${item.scope}:${item.name}`), + ["user:userMcp"], + ); + assert.deepEqual(inventory.diagnostics.projectSkillRootsProbed, [ + path.join(workspace, ".kimi-code", "skills"), + path.join(workspace, ".kimi", "skills"), + ]); + assert.ok(inventory.unsupported.length > 0); + assert.ok(inventory.unsupported.some((entry) => /memory/iu.test(entry))); + assert.equal(inventory.unsupported.some((entry) => /^plugins\b/iu.test(entry)), false); + assert.equal(inventory.unsupported.some((entry) => /^hooks\b/iu.test(entry)), false); + + const projectOnly = await collectKimiCustomizeInventory({ + kimiHome, + workspace, + includeUserHome: false, + }); + assert.equal(projectOnly.manage.skills.some((item) => item.scope === "user"), false); + assert.deepEqual(projectOnly.manage.mcps, []); + assert.deepEqual(projectOnly.plugins, []); + assert.equal(projectOnly.diagnostics.pluginCollectionSkipped, "include-user-home-disabled"); + assert.deepEqual( + projectOnly.manage.skills.filter((item) => item.scope === "project").map((item) => item.name), + ["project-kimi-code-skill", "project-kimi-skill"], + ); + assert.deepEqual( + projectOnly.manage.rules.map((item) => `${item.scope}:${item.name}`), + ["project:AGENTS.md"], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("collectKimiCustomizeInventory inventories enabled plugin assets and skips disabled ones", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-kimi-plugins-")); + const kimiHome = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "kimi-project"); + const managed = path.join(kimiHome, "plugins", "managed"); + const alphaRoot = path.join(managed, "alpha"); + const betaRoot = path.join(managed, "beta"); + const gammaRoot = path.join(managed, "gamma"); + + try { + await mkdir(workspace, { recursive: true }); + await writeJson(path.join(kimiHome, "plugins", "installed.json"), { + version: 1, + plugins: [ + { + id: "alpha", + root: alphaRoot, + source: "local-path", + enabled: true, + installedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + originalSource: "/srv/alpha", + }, + { id: "beta", root: betaRoot, source: "local-path", enabled: false }, + { id: "gamma", root: gammaRoot, source: "local-path", enabled: true }, + ], + }); + // alpha: full manifest, plus a shadow fallback manifest that must not win. + await writeJson(path.join(alphaRoot, "kimi.plugin.json"), { + name: "alpha", + version: "1.2.3", + description: "Alpha plugin.", + skills: ["./skills/", "../outside"], + agents: "./agents/", + commands: ["./commands/", "./solo.md"], + hooks: [ + { event: "PreToolUse", matcher: "Bash", command: "node ${KIMI_PLUGIN_ROOT}/hooks/check.mjs", timeout: 30 }, + { event: "SessionStart", command: "echo alpha-ready" }, + ], + mcpServers: { + "alpha-stdio": { command: "npx", args: ["-y", "@alpha/mcp-server"], env: { ALPHA_TOKEN: "fixture-alpha-secret" } }, + "alpha-http": { url: "https://mcp.example.com/alpha?key=fixture-alpha-secret" }, + }, + systemPrompt: "Always answer as alpha.", + sessionStart: { skill: "alpha-skill" }, + interface: { displayName: "Alpha Plugin" }, + }); + await writeJson(path.join(alphaRoot, ".kimi-plugin", "plugin.json"), { name: "alpha", description: "shadow" }); + await writeText( + path.join(alphaRoot, "skills", "alpha-skill", "SKILL.md"), + "---\nname: alpha-skill\ndescription: Alpha skill.\n---\n", + ); + await writeText( + path.join(managed, "outside", "outside-skill", "SKILL.md"), + "---\nname: outside-skill\ndescription: Escapes the plugin root.\n---\n", + ); + await writeText( + path.join(alphaRoot, "agents", "reviewer.md"), + "---\nname: alpha-reviewer\ndescription: Reviews code.\n---\n", + ); + await writeText( + path.join(alphaRoot, "commands", "run.md"), + "---\nname: run-alpha\ndescription: Runs alpha.\n---\n", + ); + await writeText(path.join(alphaRoot, "commands", "review", "deep.md"), "# Deep review\n"); + await writeText(path.join(alphaRoot, "solo.md"), "# Solo\n"); + // beta: installed but disabled; its assets must stay out of component collections. + await writeJson(path.join(betaRoot, "kimi.plugin.json"), { + name: "beta", + description: "Beta plugin.", + skills: "./skills/", + commands: "./commands/", + }); + await writeText(path.join(betaRoot, "skills", "beta-skill", "SKILL.md"), "---\nname: beta-skill\n---\n"); + await writeText(path.join(betaRoot, "commands", "beta-cmd.md"), "---\nname: beta-cmd\n---\n"); + // gamma: fallback manifest location, root SKILL.md as single skill root, auto-picked agents/. + await writeJson(path.join(gammaRoot, ".kimi-plugin", "plugin.json"), { + name: "gamma", + description: "Gamma plugin.", + }); + await writeText(path.join(gammaRoot, "SKILL.md"), "---\nname: gamma\ndescription: Gamma root skill.\n---\n"); + await writeText(path.join(gammaRoot, "agents", "helper.md"), "---\nname: gamma-helper\n---\n"); + + const inventory = await collectKimiCustomizeInventory({ kimiHome, workspace }); + + assert.deepEqual( + inventory.manage.plugins.map((plugin) => `${plugin.kind}:${plugin.id}:${plugin.enabled}`), + ["plugin:alpha:true", "plugin:beta:false", "plugin:gamma:true"], + ); + const alpha = inventory.plugins.find((plugin) => plugin.id === "alpha"); + assert.equal(alpha.displayName, "Alpha Plugin"); + assert.equal(alpha.description, "Alpha plugin."); + assert.equal(alpha.version, "1.2.3"); + assert.equal(alpha.installSource, "user"); + assert.equal(alpha.source, "local-path"); + assert.equal(alpha.systemPrompt, "Always answer as alpha."); + assert.equal(alpha.sessionStartSkill, "alpha-skill"); + // systemPrompt stays plugin metadata and is never merged into rules. + assert.deepEqual(inventory.manage.rules.filter((item) => item.scope === "plugin"), []); + + assert.deepEqual( + inventory.manage.skills.filter((item) => item.scope === "plugin").map((item) => `${item.name}:${item.pluginId}`), + ["alpha-skill:alpha", "gamma:gamma"], + ); + // Declared paths escaping the plugin root are skipped. + assert.equal(inventory.manage.skills.some((item) => item.name === "outside-skill"), false); + assert.deepEqual( + inventory.manage.subagents.filter((item) => item.scope === "plugin").map((item) => `${item.name}:${item.pluginId}`), + ["alpha-reviewer:alpha", "gamma-helper:gamma"], + ); + const pluginCommands = inventory.manage.commands.filter((item) => item.scope === "plugin"); + assert.deepEqual(pluginCommands.map((item) => item.name), ["review/deep", "run-alpha", "solo"]); + assert.equal(pluginCommands.every((item) => item.pluginId === "alpha"), true); + + const pluginHooks = inventory.manage.hooks.filter((item) => item.scope === "plugin"); + assert.deepEqual(pluginHooks.map((item) => item.step).sort(), ["PreToolUse", "SessionStart"]); + const preHook = pluginHooks.find((item) => item.step === "PreToolUse"); + assert.equal(preHook.pluginId, "alpha"); + assert.equal(preHook.matcher, "Bash"); + assert.equal(preHook.timeoutMs, 30000); + assert.equal(preHook.commandDisplay, "node check.mjs"); + + const pluginMcps = inventory.manage.mcps.filter((item) => item.scope === "plugin"); + assert.deepEqual(pluginMcps.map((item) => item.name), ["alpha-http", "alpha-stdio"]); + assert.equal(pluginMcps.find((item) => item.name === "alpha-http").url, "https://mcp.example.com/alpha"); + + // Disabled plugins stay listed but contribute no component assets. + const beta = inventory.plugins.find((plugin) => plugin.id === "beta"); + assert.equal(beta.enabled, false); + assert.deepEqual(beta.skills, []); + assert.equal(inventory.manage.skills.some((item) => item.pluginId === "beta"), false); + assert.equal(inventory.manage.commands.some((item) => item.pluginId === "beta"), false); + + assert.equal(inventory.diagnostics.installedPluginIndexExists, true); + assert.equal(inventory.diagnostics.installedPluginIndexParseFailed, false); + assert.equal(inventory.diagnostics.installedPluginRecordCount, 3); + assert.equal(inventory.diagnostics.enabledPluginCount, 2); + assert.equal(JSON.stringify(inventory).includes("fixture-alpha-secret"), false); + + // A missing installed.json yields an empty plugin list without throwing. + const noIndex = await collectKimiCustomizeInventory({ + kimiHome: path.join(root, "empty-home"), + workspace, + }); + assert.deepEqual(noIndex.plugins, []); + assert.equal(noIndex.diagnostics.installedPluginIndexExists, false); + assert.equal(noIndex.diagnostics.installedPluginRecordCount, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("collectKimiCustomizeInventory drops plugin assets whose realpath escapes the plugin root", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-kimi-symlink-")); + const kimiHome = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "kimi-project"); + const pluginRoot = path.join(kimiHome, "plugins", "managed", "escapee"); + const external = path.join(root, "outside-plugin-root", "external-skill"); + + try { + await mkdir(workspace, { recursive: true }); + await writeJson(path.join(kimiHome, "plugins", "installed.json"), { + version: 1, + plugins: [{ id: "escapee", root: pluginRoot, source: "local-path", enabled: true }], + }); + await writeJson(path.join(pluginRoot, "kimi.plugin.json"), { + name: "escapee", + skills: "./skills/", + agents: "./agents/", + commands: "./commands/", + }); + // A legitimate in-root skill stays inventoried. + await writeText( + path.join(pluginRoot, "skills", "inside-skill", "SKILL.md"), + "---\nname: inside-skill\ndescription: Lives inside the plugin root.\n---\n", + ); + // A symlink inside the plugin root points at a directory outside it. + await writeText( + path.join(external, "SKILL.md"), + "---\nname: external-skill\ndescription: Lives outside the plugin root.\n---\n", + ); + await symlink(external, path.join(pluginRoot, "skills", "linked-outside"), process.platform === "win32" ? "junction" : "dir"); + // Agent and command files reached only through the escaping symlink are + // dropped as well. + await writeText(path.join(root, "outside-plugin-root", "agent.md"), "---\nname: external-agent\n---\n"); + await writeText(path.join(root, "outside-plugin-root", "command.md"), "---\nname: external-command\n---\n"); + await mkdir(path.join(pluginRoot, "agents"), { recursive: true }); + await symlink(root, path.join(pluginRoot, "agents", "linked-outside"), process.platform === "win32" ? "junction" : "dir"); + await writeText(path.join(pluginRoot, "commands", "inside.md"), "---\nname: inside-command\n---\n"); + + const inventory = await collectKimiCustomizeInventory({ kimiHome, workspace }); + + const pluginSkills = inventory.manage.skills.filter((item) => item.pluginId === "escapee"); + assert.deepEqual(pluginSkills.map((item) => item.name), ["inside-skill"]); + assert.equal(pluginSkills.some((item) => item.name === "external-skill"), false); + assert.equal(inventory.manage.subagents.some((item) => item.name === "external-agent"), false); + const pluginCommands = inventory.manage.commands.filter((item) => item.pluginId === "escapee"); + assert.deepEqual(pluginCommands.map((item) => item.name), ["inside-command"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("tab availability matches Cursor Customize manage scope rules", () => { assert.equal(tabAvailableForScope("plugins", "team"), true); assert.equal(tabAvailableForScope("mcps", "team"), true); diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index b772f43..b37351a 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -116,7 +116,7 @@ function leadEvidence(overrides = {}) { } test("evidence-bundle help advertises WorkBuddy and its isolated home override", () => { - assert.match(EVIDENCE_BUNDLE_HELP, /pi, workbuddy, or grok/u); + assert.match(EVIDENCE_BUNDLE_HELP, /pi, kimi, workbuddy, or grok/u); assert.match(EVIDENCE_BUNDLE_HELP, /--workbuddy-home /u); assert.match(EVIDENCE_BUNDLE_HELP, /--grok-home /u); }); @@ -498,6 +498,30 @@ test("Pi agentCustomize lane routes the provider and isolated config paths", asy assert.equal(received["include-user-home"], true); }); +test("Kimi agentCustomize lane routes the provider and isolated config paths", async () => { + const context = freezeEvidenceBundleContext({ + workspace: ".", + platform: "kimi", + depth: "quick", + "include-user-home": true, + }, NOW); + let received; + const lane = await collectAgentCustomize(context, { + "kimi-home": "/tmp/fixture-kimi-home", + }, { + collectAssetBaseline: async (options) => { + received = options; + return { kind: "agent-asset-baseline", status: "complete" }; + }, + }); + + assert.equal(lane.status, "available"); + assert.equal(received.provider, "kimi"); + assert.equal(received["kimi-home"], "/tmp/fixture-kimi-home"); + assert.equal(received["include-user-home"], true); +}); + + test("shared Session population excludes the active session before both lanes hydrate", async () => { const population = Object.freeze({ sessions: Object.freeze([{ sessionId: "eligible-session" }]), @@ -648,6 +672,7 @@ test("zero-signal Episode admission remains valid inside one bound population", }); }); + test("WorkBuddy agentCustomize lane routes the provider and isolated config paths", async () => { const context = freezeEvidenceBundleContext({ workspace: ".", diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 4b953c5..ac8ed83 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, WorkBuddy, or Grok, or a portable visual is explicitly requested \|/, + /Portable HTML report \| Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, Kimi Code, 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/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index 51b9751..b286b3f 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -47,7 +47,7 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.pi/agent/sessions", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.workbuddy/projects", "## Platform Notes", "Sessions Diagnostics"); 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, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, `kimi`, `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/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 1fc830e..98aa5c4 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, WorkBuddy, and Grok session evidence + Pi, Kimi, 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 ae81ce9..15a6b32 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); --grok-home overrides the Grok data root (default: ~/.grok or $GROK_HOME). +Options: --kimi-home overrides the Kimi Code data root (default: ~/.kimi-code); --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/harness-report-quality.test.mjs b/test/harness-report-quality.test.mjs index 6e14e2b..b25fd17 100644 --- a/test/harness-report-quality.test.mjs +++ b/test/harness-report-quality.test.mjs @@ -1118,6 +1118,56 @@ Current state is L3 candidate because Codex assets appear configured. assert.ok(quality.errors.some((error) => /session-analysis\.mjs facets/i.test(error))); }); +test("harness report quality flags kimi-only session scope without session-analysis evidence", () => { + const report = `# Better Harness Readiness Report + +## Executive Verdict + +Current state is L3 candidate because Kimi assets appear configured. + +## Evidence Boundary + +- AI Agent practice scope: inspected ~/.kimi-code/sessions, Skills, and Session Insights. + +## Risk Findings + +| Severity | Finding | Affected subsystem | Blast radius | Evidence strength | Root-cause chain | Risk if unfixed | Pass check | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Medium | Session evidence missing | Kimi practice analysis | Runtime behavior may be inferred from static files | Confirmed report gap | .kimi-code inventory -> no workspace session probe -> runtime unknown | Reports may claim workflow habits without current-project session evidence | Run workspace-scoped session-analysis sources and facets | + +## Readiness Scorecard + +| Dimension | Level | Confidence | Strongest evidence | Main gap | +| --- | --- | --- | --- | --- | +| Adaptive Engineering Loop | L3 | Medium | Kimi session files exist | session-analysis not cited | + +## Signals And Diagnosis + +### AI Agent Practices + +| Surface | Evidence | Diagnosis | Confidence | +| --- | --- | --- | --- | +| Skills | Project skill files exist | candidate positive practice evidence | Medium | +| Session Insights | session habits are asserted | unsupported without analyzer output | Low | + +## Action Pathways + +| Pathway | Trigger evidence | Next action | Pass check | Owner | Timing | Evidence artifact | Impact | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Session analysis | .kimi-code is in scope | run sources and facets for this workspace | commands are cited | maintainer | Now | session-analysis output | Medium | + +## Unverified Items + +- Workspace session coverage. +`; + + const quality = evaluateHarnessReportQuality(report); + + assert.equal(quality.status, "fail"); + assert.ok(quality.errors.some((error) => /session-analysis\.mjs sources/i.test(error))); + assert.ok(quality.errors.some((error) => /session-analysis\.mjs facets/i.test(error))); +}); + test("harness report quality requires practice surfaces inside the practice diagnosis", () => { const report = `# Better Harness Readiness Report diff --git a/test/plugin-manifests.test.mjs b/test/plugin-manifests.test.mjs index f0e3eca..95a5fc2 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -106,6 +106,7 @@ test("host plugin manifests expose canonical Better Harness resources", () => { const codex = readJson(".codex-plugin/plugin.json"); const qwen = readJson("qwen-extension.json"); const copilot = readJson(".github/plugin/plugin.json"); + const kimi = readJson(".kimi-plugin/plugin.json"); const copilotMarketplace = readJson(".github/plugin/marketplace.json"); const cursorMarketplace = readJson(".cursor-plugin/marketplace.json"); const packageJson = readJson("package.json"); @@ -171,6 +172,18 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(copilot.hooks, undefined); assert.equal(copilot.license, "MIT"); + assert.equal(kimi.name, qoder.name); + assert.equal(kimi.version, qoder.version); + assert.equal(kimi.description, qoder.description); + assert.equal(kimi.author.name, qoder.author.name); + assert.equal(kimi.author.email, qoder.author.email); + assert.deepEqual(Object.keys(kimi.author).sort(), ["email", "name"]); + assert.ok(kimi.keywords.includes("skills")); + assert.equal(kimi.skills, "./skills/"); + assert.equal(kimi.license, "MIT"); + assert.equal(kimi.homepage, "https://github.com/QoderAI/better-harness"); + assert.equal(kimi.interface.displayName, qoder.displayName); + assert.deepEqual(packageJson.pi, { skills: ["./skills"], prompts: ["./prompts"] }); assert.ok(packageJson.keywords.includes("pi-package"), "package keywords should mark the pi package"); assert.equal(cursor.license, "MIT"); @@ -183,7 +196,7 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(packageLock.packages[""].name, packageJson.name); assert.equal(packageLock.packages[""].license, packageJson.license); assert.match(packageJson.scripts["publish:dry-run"], /registry\.npmjs\.org/u); - for (const manifest of [qoder, claude, cursor, codex, copilot]) { + for (const manifest of [qoder, claude, cursor, codex, copilot, kimi]) { assert.equal(manifest.homepage, "https://github.com/QoderAI/better-harness"); assert.equal(manifest.repository, "https://github.com/QoderAI/better-harness"); } @@ -237,6 +250,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays ".cursor-plugin/", ".github/plugin/", ".qoder-plugin/", + ".kimi-plugin/", "qwen-extension.json", "prompts/", "AGENTS.md", @@ -292,6 +306,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /package\/\.cursor-plugin\/plugin\.json/u); assert.match(verifyScript, /package\/\.cursor-plugin\/marketplace\.json/u); assert.match(verifyScript, /package\/qwen-extension\.json/u); + assert.match(verifyScript, /package\/\.kimi-plugin\/plugin\.json/u); assert.match(verifyScript, /package\/prompts\/better-harness\.md/u); assert.doesNotMatch(verifyScript, /package\/schemas\/proactive-trigger\.v1\.schema\.json/u); assert.doesNotMatch(verifyScript, /package\/scripts\/proactive\/trigger\.mjs/u); @@ -302,6 +317,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.match(verifyScript, /"\.claude-plugin\/"/u); assert.match(verifyScript, /"\.codex-plugin\/"/u); assert.match(verifyScript, /"\.cursor-plugin\/"/u); + assert.match(verifyScript, /"\.kimi-plugin\/"/u); assert.match(verifyScript, /"qwen-extension\.json"/u); assert.match(verifyScript, /package\/scripts\/packaging\//u); assert.match(verifyScript, /"scripts\/packaging\/"/u); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 29b0053..b16e6d2 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: "4c3779a9cf5d7caa32d754c1463f0d734e8f6db717a4d32fc40f8bef09283176", + sha256: "f7da6a2d87a1746fbe85de6573091e92dd988002a82920145bd00ebf6d930faa", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "1ab508b17645fe84df492e58386cdab505f5b3a525edbf63b9bfa2f34d9a373b", + sha256: "6b6a55573a338cf942ed3856d31c26dffdf295d9e3bf5a9966d499608d5ce431", }, { label: "Harness command description", diff --git a/test/session-analysis-fs.test.mjs b/test/session-analysis-fs.test.mjs new file mode 100644 index 0000000..0c9122e --- /dev/null +++ b/test/session-analysis-fs.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { walkFiles } from "../scripts/session-analysis/fs.mjs"; +import { collectSkillFiles } from "../scripts/agent-customize/core/items.mjs"; + +const SYMLINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + +async function withTempDir(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), "walkfiles-symlink-")); + try { + // mkdtemp may return an 8.3 short path on Windows; normalize to the + // realpath so assertions compare like for like. + const real = await realpath(dir); + return await fn(real); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function writeSkill(dir, name) { + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "SKILL.md"), `# ${name}\n`, "utf8"); +} + +test("walkFiles does not follow directory symlinks by default", async () => { + await withTempDir(async (root) => { + const real = path.join(root, "real-skill"); + const linked = path.join(root, "linked-skill"); + await writeSkill(real, "real"); + await writeSkill(path.join(root, "link-target", "target-skill"), "linked"); + await symlink(path.join(root, "link-target", "target-skill"), linked, SYMLINK_TYPE); + + const files = await walkFiles(root, { + match: (file) => path.basename(file) === "SKILL.md", + }); + const names = files.map((file) => path.basename(path.dirname(file))).sort(); + assert.deepEqual(names, ["real-skill", "target-skill"]); + }); +}); + +test("walkFiles with followSymlinks: true collects files inside linked directories", async () => { + await withTempDir(async (root) => { + const target = path.join(root, "target", "linked-skill"); + await writeSkill(path.join(root, "real-skill"), "real"); + await writeSkill(target, "linked"); + await symlink(path.dirname(target), path.join(root, "linked-skill"), SYMLINK_TYPE); + + const files = await walkFiles(root, { + match: (file) => path.basename(file) === "SKILL.md", + followSymlinks: true, + }); + const names = files.map((file) => path.basename(path.dirname(file))).sort(); + // walkFiles dedupes directories by realpath, so the linked directory is + // collected once even though it is reachable through two paths. + assert.deepEqual(names, ["linked-skill", "real-skill"]); + }); +}); + +test("walkFiles with followSymlinks does not loop forever on a self-referencing link cycle", async () => { + await withTempDir(async (root) => { + await writeSkill(path.join(root, "skill-a"), "a"); + await symlink(root, path.join(root, "loop"), SYMLINK_TYPE); + + const files = await walkFiles(root, { + maxDepth: 10, + match: (file) => path.basename(file) === "SKILL.md", + followSymlinks: true, + }); + // The link cycle is deduplicated by realpath, so skill-a is found once. + assert.equal(files.length, 1); + assert.equal(path.basename(path.dirname(files[0])), "skill-a"); + }); +}); + +test("walkFiles with followSymlinks skips broken symlinks", async () => { + await withTempDir(async (root) => { + await symlink(path.join(root, "missing-target"), path.join(root, "broken"), SYMLINK_TYPE); + const files = await walkFiles(root, { followSymlinks: true }); + assert.deepEqual(files, []); + }); +}); + +test("collectSkillFiles discovers skills installed via symlinks", async () => { + await withTempDir(async (root) => { + const target = path.join(root, "repo", "skills", "linked-skill"); + await writeSkill(target, "linked"); + const skillsRoot = path.join(root, "kimi-home", "skills"); + await mkdir(skillsRoot, { recursive: true }); + await symlink(path.dirname(target), path.join(skillsRoot, "linked-skill"), SYMLINK_TYPE); + + const items = await collectSkillFiles(skillsRoot, "user", "User", path.join(root, "kimi-home")); + assert.equal(items.length, 1); + assert.equal(items[0].kind, "skill"); + assert.equal(items[0].name, "linked-skill"); + }); +}); diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index a2ca234..ead1ddd 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -43,6 +43,7 @@ import { CopilotSessionAnalyzer, parseWorkspaceDescriptor, } from "../scripts/session-analysis/platforms/copilot.mjs"; +import { KimiSessionAnalyzer } from "../scripts/session-analysis/platforms/kimi.mjs"; import { measureLongSessionRows } from "../scripts/session-analysis/long-sessions.mjs"; async function fixtureRoot(prefix) { @@ -872,6 +873,156 @@ test("Pi provider expands tool calls, tool results, and usage from v3 transcript assert.doesNotMatch(JSON.stringify(facts), new RegExp(home.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); }); +test("Kimi provider resolves wd_* dirs through workspaces.json and normalizes wire events", async () => { + const root = await fixtureRoot("session-kimi-provider-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const foreign = path.join(root, "workspace", "other"); + const sessionId = "session_77777777-7777-4777-8777-777777777777"; + const sessionDir = path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId); + const foreignDir = path.join(home, "sessions", "wd_other_ab12cd34ef56", "session_88888888-8888-4888-8888-888888888888"); + await mkdir(workspace, { recursive: true }); + await mkdir(home, { recursive: true }); + await writeFile(path.join(home, "workspaces.json"), JSON.stringify({ + version: 1, + workspaces: { + wd_project_ab12cd34ef56: { root: workspace, name: "project" }, + wd_other_ab12cd34ef56: { root: foreign, name: "other" }, + }, + })); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "state.json"), JSON.stringify({ + title: "Fixture session", + createdAt: "2026-07-20T01:00:00.000Z", + updatedAt: "2026-07-20T01:05:00.000Z", + })); + await writeJsonl(path.join(sessionDir, "agents", "main", "wire.jsonl"), [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + { + type: "turn.prompt", + input: [{ type: "text", text: "Implement the kimi adapter" }], + origin: { kind: "user" }, + time: Date.parse("2026-07-20T01:00:01.000Z"), + }, + { + type: "context.append_loop_event", + event: { type: "step.begin", uuid: "step-1", turnId: "0", step: 1 }, + time: Date.parse("2026-07-20T01:00:02.000Z"), + }, + { + type: "context.append_loop_event", + event: { + type: "tool.call", + uuid: "tool-1", + turnId: "0", + step: 1, + toolCallId: "tool-1", + name: "Bash", + args: { command: "npm test" }, + }, + time: Date.parse("2026-07-20T01:01:00.000Z"), + }, + { + type: "context.append_loop_event", + event: { + type: "tool.result", + parentUuid: "tool-1", + toolCallId: "tool-1", + result: { output: "3 tests passed", isError: false }, + }, + time: Date.parse("2026-07-20T01:02:00.000Z"), + }, + { + type: "usage.record", + model: "kimi-code/kimi-fixture", + usage: { inputOther: 10, output: 4, inputCacheRead: 6, inputCacheCreation: 0 }, + usageScope: "turn", + time: Date.parse("2026-07-20T01:03:00.000Z"), + }, + ]); + await writeJsonl(path.join(foreignDir, "agents", "main", "wire.jsonl"), [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + ]); + await writeFile(path.join(foreignDir, "state.json"), JSON.stringify({ + title: "Foreign session", + createdAt: "2026-07-20T01:00:00.000Z", + updatedAt: "2026-07-20T01:05:00.000Z", + })); + + const analyzer = new KimiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.equal(discovery.sessions[0].sessionId, sessionId); + assert.equal(discovery.sessions[0].title, "Fixture session"); + assert.deepEqual( + discovery.sources.map((source) => source.kind), + ["kimi-wire-jsonl", "kimi-session-index-jsonl", "kimi-workspaces-json"], + ); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { + includeCommandText: true, + includeUserText: true, + }); + 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.find((event) => event.type === "tool.result")?.success, true); + const usage = events.find((event) => event.model === "kimi-code/kimi-fixture"); + assert.equal(usage?.modelUsage.inputTokens, 16); + assert.equal(usage?.modelUsage.outputTokens, 4); + const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); + assert.equal(facts.kind, "session-core-facts"); + assert.equal(facts.scope.platform, "kimi"); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(sessionId, "u")); +}); + +test("Kimi keeps partial and malformed usage explicit instead of zero-filling", async () => { + const root = await fixtureRoot("session-kimi-usage-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "session_66666666-6666-4666-8666-666666666666"; + const sessionDir = path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId); + await mkdir(workspace, { recursive: true }); + await mkdir(home, { recursive: true }); + await writeFile(path.join(home, "workspaces.json"), JSON.stringify({ + version: 1, + workspaces: { wd_project_ab12cd34ef56: { root: workspace, name: "project" } }, + })); + await writeJsonl(path.join(sessionDir, "agents", "main", "wire.jsonl"), [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + { + type: "usage.record", + model: "kimi-partial", + usage: { output: 4 }, + time: Date.parse("2026-07-20T01:01:00.000Z"), + }, + { + type: "usage.record", + model: "kimi-malformed", + usage: { inputOther: "10", inputCacheRead: null }, + time: Date.parse("2026-07-20T01:02:00.000Z"), + }, + { + type: "usage.record", + model: "kimi-missing", + time: Date.parse("2026-07-20T01:03:00.000Z"), + }, + ]); + + const analyzer = new KimiSessionAnalyzer(); + 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, {}); + const usageEvents = events.filter((event) => event.type === "model.response.completed"); + // Partial usage carries only the observed fields; malformed or missing + // usage never becomes zero-filled, and without one finite field there is no + // usage event at all. + assert.equal(usageEvents.length, 1); + assert.equal(usageEvents[0].model, "kimi-partial"); + assert.deepEqual(usageEvents[0].modelUsage, { outputTokens: 4 }); + assert.equal(Object.hasOwn(usageEvents[0].modelUsage, "inputTokens"), false); + assert.equal(Object.hasOwn(usageEvents[0].modelUsage, "cacheReadInputTokens"), false); +}); + test("Pi provider rejects a transcript whose header cwd belongs to another workspace", async () => { const root = await fixtureRoot("session-pi-isolation-"); const home = path.join(root, ".pi", "agent"); @@ -1089,6 +1240,191 @@ test("Pi custom session roots require a directory", async () => { assert.equal(result.sessions.length, 0); }); +test("Kimi provider falls back to session_index.jsonl when workspaces.json has no entry", async () => { + const root = await fixtureRoot("session-kimi-index-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "ses_99999999-9999-4999-8999-999999999999"; + const sessionDir = path.join(home, "sessions", "wd_project_ff00ff00ff00", sessionId); + await mkdir(workspace, { recursive: true }); + await writeJsonl(path.join(home, "session_index.jsonl"), [ + { sessionId, sessionDir, workDir: workspace }, + ]); + await writeJsonl(path.join(sessionDir, "agents", "main", "wire.jsonl"), [ + { + type: "context.append_message", + message: { role: "user", content: [{ type: "text", text: "legacy protocol" }], toolCalls: [] }, + time: Date.parse("2026-07-20T01:00:00.000Z"), + }, + ]); + const discovery = await new KimiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.equal(discovery.sessions[0].sessionId, sessionId); +}); + +test("Kimi provider falls back to wd__* prefixes when both workspace indexes are absent", async () => { + const root = await fixtureRoot("session-kimi-prefix-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "ses_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const wire = [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + ]; + await mkdir(workspace, { recursive: true }); + await writeJsonl( + path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId, "agents", "main", "wire.jsonl"), + wire, + ); + await writeFile( + path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId, "state.json"), + JSON.stringify({ + title: "Fallback session", + createdAt: "2026-07-20T01:00:00.000Z", + updatedAt: "2026-07-20T01:05:00.000Z", + }), + ); + // wd_projectextra_* does not start with the wd_project_ prefix and must be excluded. + await writeJsonl( + path.join(home, "sessions", "wd_projectextra_ab12cd34ef56", "ses_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "agents", "main", "wire.jsonl"), + wire, + ); + // A different project name must be excluded too. + await writeJsonl( + path.join(home, "sessions", "wd_other_ab12cd34ef56", "ses_cccccccc-cccc-4ccc-8ccc-cccccccccccc", "agents", "main", "wire.jsonl"), + wire, + ); + + const analyzer = new KimiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.deepEqual(discovery.sessions.map((session) => session.sessionId), [sessionId]); + assert.ok(discovery.warnings.some((warning) => warning.code === "kimi-workspace-index-absent")); + + const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); + assert.ok(facts.warningCodes.includes("kimi-workspace-index-absent")); +}); + +test("Kimi prefix fallback lowercases the workspace basename and keeps raw directory characters", async () => { + const root = await fixtureRoot("session-kimi-prefix-case-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "My Project"); + const sessionId = "ses_dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + await mkdir(workspace, { recursive: true }); + // Actual behavior: the fallback lowercases both sides but applies no other + // sanitization, so an uppercase dir with a space and uppercase hex suffix still + // matches the wd_my project_ prefix. + await writeJsonl( + path.join(home, "sessions", "wd_MY PROJECT_AB12CD34EF56", sessionId, "agents", "main", "wire.jsonl"), + [{ type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }], + ); + + const discovery = await new KimiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.deepEqual(discovery.sessions.map((session) => session.sessionId), [sessionId]); + assert.ok(discovery.warnings.some((warning) => warning.code === "kimi-workspace-index-absent")); +}); + +test("Kimi provider emits kimi-workspace-index-absent only when both indexes are missing", async () => { + const root = await fixtureRoot("session-kimi-index-warning-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "ses_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + await mkdir(workspace, { recursive: true }); + await writeJsonl( + path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId, "agents", "main", "wire.jsonl"), + [{ type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }], + ); + + const analyzer = new KimiSessionAnalyzer(); + const missing = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(missing.sessions.length, 1); + assert.ok(missing.warnings.some((warning) => warning.code === "kimi-workspace-index-absent")); + + // An existing (even empty) workspaces.json marks the index as present, so the + // warning disappears. The prefix fallback itself keys off the empty index maps, + // not file existence, so the session is still attributed. + await writeFile( + path.join(home, "workspaces.json"), + JSON.stringify({ version: 1, workspaces: {} }), + ); + const indexed = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(indexed.sessions.length, 1); + assert.equal( + indexed.warnings.some((warning) => warning.code === "kimi-workspace-index-absent"), + false, + ); +}); + +test("Kimi provider merges main and subagent wire files and dedupes repeated tool events", async () => { + const root = await fixtureRoot("session-kimi-subagent-"); + const home = path.join(root, ".kimi-code"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "ses_ffffffff-ffff-4fff-8fff-ffffffffffff"; + const sessionDir = path.join(home, "sessions", "wd_project_ab12cd34ef56", sessionId); + await mkdir(workspace, { recursive: true }); + await mkdir(home, { recursive: true }); + await writeFile(path.join(home, "workspaces.json"), JSON.stringify({ + version: 1, + workspaces: { wd_project_ab12cd34ef56: { root: workspace, name: "project" } }, + })); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, "state.json"), JSON.stringify({ + title: "Subagent session", + createdAt: "2026-07-20T01:00:00.000Z", + updatedAt: "2026-07-20T01:10:00.000Z", + })); + await writeJsonl(path.join(sessionDir, "agents", "main", "wire.jsonl"), [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + { + type: "context.append_loop_event", + event: { type: "tool.call", uuid: "tool-1", toolCallId: "tool-1", name: "Bash", args: { command: "npm test" } }, + time: Date.parse("2026-07-20T01:01:00.000Z"), + }, + { + type: "context.append_loop_event", + event: { type: "tool.result", toolCallId: "tool-1", result: { output: "ok", isError: false } }, + time: Date.parse("2026-07-20T01:02:00.000Z"), + }, + ]); + await writeJsonl(path.join(sessionDir, "agents", "researcher", "wire.jsonl"), [ + { type: "metadata", protocol_version: "1.4", created_at: Date.parse("2026-07-20T01:00:00.000Z") }, + // Same toolInvocationId + lifecyclePhase as the main wire record: dedupeEvents + // must drop this duplicate even though it comes from another agent's file. + { + type: "context.append_loop_event", + event: { type: "tool.call", uuid: "tool-1", toolCallId: "tool-1", name: "Bash", args: { command: "npm test" } }, + time: Date.parse("2026-07-20T01:03:00.000Z"), + }, + { + type: "context.append_loop_event", + event: { + type: "tool.call", + uuid: "tool-2", + toolCallId: "tool-2", + name: "Read", + args: { file_path: path.join(workspace, "notes.md") }, + }, + time: Date.parse("2026-07-20T01:04:00.000Z"), + }, + ]); + + const analyzer = new KimiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.deepEqual( + discovery.sessions[0].sourceRefs.map((ref) => `${ref.agentId}:${ref.role}`), + ["main:session-transcript", "researcher:subagent-transcript"], + ); + + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { includeCommandText: true }); + const toolCalls = events.filter((event) => event.type === "tool.call"); + assert.deepEqual(toolCalls.map((event) => event.toolInvocationId), ["tool-1", "tool-2"]); + // The surviving tool-1 copy is the first occurrence, from the main agent wire file. + assert.equal(toolCalls[0].isSubagent, false); + assert.equal(toolCalls[1].isSubagent, true); + assert.equal(events.filter((event) => event.type === "tool.result").length, 1); + assert.equal(events.filter((event) => event.type === "metadata.wire").length, 2); +}); + test("WorkBuddy provider expands tool calls, tool results, and usage from JSONL transcripts", async () => { const root = await fixtureRoot("session-workbuddy-provider-"); const home = path.join(root, ".workbuddy"); diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index f06d389..8852e2e 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, WorkBuddy, or Grok, or a portable visual is explicitly requested \|/, + /Active host is Claude Code, Codex, Qwen Code, GitHub Copilot, Pi, Kimi Code, WorkBuddy, or Grok, or a portable visual is explicitly requested \|/, ); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index ff2b629..6e8f7d2 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", "grok"]; +const SUPPORTED_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi", "workbuddy", "grok"]; const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); const adapterMatrixPath = path.join(process.cwd(), "docs", "adapters", "README.md");