From b38c92a40330149c30f9666387c4646c68eeed14 Mon Sep 17 00:00:00 2001 From: lijiahao6649 Date: Thu, 30 Jul 2026 20:18:46 +0800 Subject: [PATCH] feat(hosts): add Kimi Code host adapter - Add Kimi configured-asset provider with managed-plugin inventory - Add Kimi session source adapter (wire.jsonl, workspace index fallback) - Add .kimi-plugin/plugin.json shell for /plugins install discovery - Support symlinked skill directories in walkFiles (opt-in, cycle-safe) - Register kimi across evidence-bundle, report, agent-lint, and asset tools - Sync README/zh-CN, adapter docs, routing, spec, and CHANGELOG --- .kimi-plugin/plugin.json | 25 + CHANGELOG.md | 14 + README.md | 32 +- README.zh-CN.md | 27 +- docs/ARCHITECTURE.md | 2 +- docs/adapters/README.md | 44 +- docs/adrs/directory-structure.md | 4 +- docs/community.md | 2 +- docs/concepts.md | 2 +- docs/glossary.md | 4 +- docs/specs/2026-07-30-kimi-host-support.md | 192 ++++++ package.json | 1 + references/agent-customize/README.md | 3 +- references/agent-customize/global-assets.md | 35 +- references/agent-customize/platforms/kimi.md | 88 +++ references/agent-customize/routing.md | 39 +- .../session-evidence/sessions-diagnostics.md | 33 +- scripts/agent-customize/cli.mjs | 6 +- scripts/agent-customize/core/items.mjs | 110 +++- scripts/agent-customize/providers/claude.mjs | 86 +-- scripts/agent-customize/providers/index.mjs | 2 + scripts/agent-customize/providers/kimi.mjs | 339 +++++++++++ scripts/agent-lint/cli.mjs | 11 +- scripts/better-harness-cli/registry.mjs | 2 +- .../coding-agent-practices/asset-baseline.mjs | 7 +- .../asset-integrity.mjs | 7 +- scripts/coding-agent-practices/inventory.mjs | 15 +- .../evidence-bundle/agent-customize.mjs | 2 +- .../harness-analysis/evidence-bundle/cli.mjs | 5 +- .../evidence-bundle/contract.mjs | 2 +- scripts/harness-analysis/report-quality.mjs | 2 +- scripts/harness-analysis/report-run.mjs | 7 +- scripts/harness-analysis/task-loop-report.mjs | 2 +- scripts/harness-analysis/task-loop-source.mjs | 18 +- scripts/npm-package/verify-pack.mjs | 3 + scripts/session-analysis.mjs | 11 +- scripts/session-analysis/analyzer.mjs | 19 +- scripts/session-analysis/fs.mjs | 44 +- .../lifecycle-demand-signals.mjs | 2 +- scripts/session-analysis/platforms/kimi.mjs | 556 ++++++++++++++++++ .../session-analysis/selection-profile.mjs | 2 +- scripts/session-analysis/usage-summary.mjs | 2 +- templates/reporting/routing.md | 2 +- test/agent-asset-baseline.test.mjs | 31 + test/agent-customize-architecture.test.mjs | 10 +- test/agent-customize.test.mjs | 287 ++++++++- test/better-harness-evidence-bundle.test.mjs | 23 + test/better-harness-skill.test.mjs | 2 +- test/coding-agent-platform-notes.test.mjs | 2 +- .../scripts-refactor-contract/root-help.txt | 2 +- .../session-help.txt | 2 +- test/harness-report-quality.test.mjs | 50 ++ test/plugin-manifests.test.mjs | 18 +- test/scripts-refactor-contract.test.mjs | 4 +- test/session-analysis-fs.test.mjs | 98 +++ test/session-analysis-providers.test.mjs | 336 +++++++++++ test/style-templates.test.mjs | 2 +- test/support-declarations.test.mjs | 2 +- 58 files changed, 2497 insertions(+), 183 deletions(-) create mode 100644 .kimi-plugin/plugin.json create mode 100644 docs/specs/2026-07-30-kimi-host-support.md create mode 100644 references/agent-customize/platforms/kimi.md create mode 100644 scripts/agent-customize/providers/kimi.mjs create mode 100644 scripts/session-analysis/platforms/kimi.mjs create mode 100644 test/session-analysis-fs.test.mjs diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json new file mode 100644 index 0000000..17d81b1 --- /dev/null +++ b/.kimi-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "better-harness", + "version": "0.3.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 fddae48..7b6c270 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. + - Pi (pi.dev) is now a supported analysis-capable source-local host. The repository installs as a pi package (`pi install `) through a `pi` manifest in `package.json`, registers a `/better-harness` prompt template, diff --git a/README.md b/README.md index 3713323..5c9de39 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Once installed, ask Better Harness to generate the host's durable report: Better Harness scopes behavior claims to relevant Task Episodes and the surrounding project mechanisms. Qoder produces a Canvas report; Claude Code, -Codex, Cursor, Qwen Code, and GitHub Copilot produce self-contained HTML with -paired Markdown. Missing or partial evidence remains explicit. See the +Codex, Cursor, 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. @@ -371,6 +371,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 ee04b47..bda9e69 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,7 +42,7 @@ ``` Better Harness 会将行为断言限定在相关的任务过程片段(Task Episode)及其周边项目机制内。 -Qoder 生成 Canvas 报告;Claude Code、Codex、Cursor、Qwen Code 和 GitHub Copilot 生成自包含的 HTML 报告及配套 Markdown。 +Qoder 生成 Canvas 报告;Claude Code、Codex、Cursor、Qwen Code、GitHub Copilot 和 Kimi Code 生成自包含的 HTML 报告及配套 Markdown。 缺失或不完整的证据会被明确标注。有关当前覆盖范围和输出差异,请参阅 [宿主适配器矩阵](docs/adapters/README.md)。 @@ -350,6 +350,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 f4ec0bb..a3644ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,7 +66,7 @@ reference is `docs/adrs/directory-structure.md`. 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/`. ## Template Boundaries diff --git a/docs/adapters/README.md b/docs/adapters/README.md index cf683a8..0b449b1 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -1,9 +1,11 @@ # Host Adapter Matrix This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, -GitHub Copilot, and Pi 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`, or `docs/adapters/pi.md` by default. +GitHub Copilot, Pi, and Kimi Code host boundaries. Do not create +`docs/adapters/claude-code.md`, `docs/adapters/codex.md`, +`docs/adapters/qoder.md`, `docs/adapters/cursor.md`, `docs/adapters/qwen.md`, +`docs/adapters/copilot.md`, `docs/adapters/pi.md`, or +`docs/adapters/kimi-code.md` by default. Adding another host? Follow [Contributing a New Coding Agent Host](contributing-new-coding-agent.md) before @@ -16,13 +18,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 | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -33,6 +37,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 | ## Discovery And Evidence @@ -93,6 +98,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). ## Output Modes @@ -100,7 +122,7 @@ Canonical templates live under `templates/reporting/`. - `qoder-canvas.md`: Qoder Canvas output contract, covering renderer-owned `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. -- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi/Kimi Code 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 7820e93..a99f1a7 100644 --- a/docs/adrs/directory-structure.md +++ b/docs/adrs/directory-structure.md @@ -76,7 +76,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 host adapters + platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen/Copilot/Pi/Kimi host adapters ides// # target editor-local evidence not covered by host adapters / # [target] new capability owner cli.mjs # use cli.mjs for new capabilities @@ -214,7 +214,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 26bdaac..c15a061 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, or Pi session behavior | +| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or Kimi Code 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 3643099..bf41f3d 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, or Pi agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | +| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or Kimi Code 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 a7ddafd..83d549a 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 3a1fb49..f97ec6a 100644 --- a/references/agent-customize/README.md +++ b/references/agent-customize/README.md @@ -15,7 +15,8 @@ authority, routing, overlap, observed use, and maintenance boundaries. `custom-agents-review.md`, and `knowledge-assets-review.md`. - Inventory and authority: `global-assets.md`. - Provider-specific notes: `platforms/claude.md`, `platforms/codex.md`, - `platforms/qoder.md`, `platforms/qwen.md`, `platforms/copilot.md`, and `platforms/pi.md`. + `platforms/qoder.md`, `platforms/qwen.md`, `platforms/copilot.md`, + `platforms/pi.md`, and `platforms/kimi.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 9a818c2..67c86ad 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 @@ -400,3 +401,37 @@ evidence. Use the Global/User Asset Pass from `global-assets.md` when the 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. diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index 1e0ff3a..190abfb 100644 --- a/references/session-evidence/sessions-diagnostics.md +++ b/references/session-evidence/sessions-diagnostics.md @@ -24,10 +24,10 @@ fails validation: 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`, and `pi`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, and `kimi`. 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. @@ -307,3 +307,30 @@ tool results as provider-labelled coverage. Entries such as `model_change`, (`AGENTS.md`), Skills, prompt templates, extensions, pi packages, and other 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. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index 546eebf..689bc50 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, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --copilot-home, --pi-home, --kimi-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } @@ -33,6 +33,7 @@ function summarize(inventory, options) { copilotHome: inventory.copilotHome, piHome: inventory.piHome, claudeStatePath: inventory.claudeStatePath, + kimiHome: inventory.kimiHome, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, workspace: inventory.workspace, @@ -77,6 +78,7 @@ async function main() { copilotHome: options["copilot-home"], piHome: options["pi-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 af53428..4e09c05 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 27e8669..75906d1 100644 --- a/scripts/agent-customize/providers/claude.mjs +++ b/scripts/agent-customize/providers/claude.mjs @@ -1,4 +1,3 @@ -import { realpath } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -17,9 +16,12 @@ import { collectSkillFiles, evidence, normalizePluginDisplayName, + pathInsideRoot, pluginMetadataEvidencePath, + pluginPathValues, readJson, readText, + sanitizeMcpItems, sortByName, titleCase, uniqueAssetsByRealPath, @@ -71,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 componentRoots(pluginRoot, manifest, key, fallback, options = {}) { const declared = pluginPathValues(manifest?.[key]); const requested = options.addDefault @@ -124,62 +100,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 67d4566..aab4454 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -3,6 +3,7 @@ import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; import { collectPiCustomizeInventory } from "./pi.mjs"; +import { collectKimiCustomizeInventory } from "./kimi.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; import { collectQwenCustomizeInventory } from "./qwen.mjs"; @@ -14,6 +15,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["qwen", collectQwenCustomizeInventory], ["copilot", collectCopilotCustomizeInventory], ["pi", collectPiCustomizeInventory], + ["kimi", collectKimiCustomizeInventory], ]); export async function collectProviderInventory(provider, options = {}) { diff --git a/scripts/agent-customize/providers/kimi.mjs b/scripts/agent-customize/providers/kimi.mjs new file mode 100644 index 0000000..3373406 --- /dev/null +++ b/scripts/agent-customize/providers/kimi.mjs @@ -0,0 +1,339 @@ +import os from "node:os"; +import path from "node:path"; + +import { pathExists, pathStat, walkFiles } from "../../session-analysis/fs.mjs"; +import { expandHome, normalizeWorkspace } from "../../session-analysis/paths.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 670c083..cf71f3f 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 5ff9305..bd7ade2 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, and Pi session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, and Kimi session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index 9c87931..7b0c06f 100644 --- a/scripts/coding-agent-practices/asset-baseline.mjs +++ b/scripts/coding-agent-practices/asset-baseline.mjs @@ -15,7 +15,7 @@ export const ASSET_BASELINE_SCHEMA_VERSION = 1; export const MAX_BASELINE_FINDINGS = 16; export const MAX_BASELINE_OWNER_ROUTES = 16; -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"]); const SEVERITY_RANK = Object.freeze({ error: 0, warning: 1, advisory: 2 }); const OWNER_KIND_RANK = Object.freeze({ rules: 0, @@ -200,7 +200,7 @@ function available(data) { 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.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, kimi.`); } const workspace = normalizeWorkspace(options.workspace ?? "."); const includeUserHome = parseBooleanFlag(options.includeUserHome ?? options["include-user-home"] ?? false); @@ -300,7 +300,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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot|pi|kimi] [options] Collect one compact, read-only AI evidence envelope from a shared asset snapshot. @@ -310,6 +310,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 a770185..006884d 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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot|pi|kimi] [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 @@ -368,8 +369,8 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const provider = options.provider ?? options.platform ?? command ?? "qoder"; - if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi.`); + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"].includes(provider)) { + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi, kimi.`); } 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 b8df856..c1decb0 100644 --- a/scripts/coding-agent-practices/inventory.mjs +++ b/scripts/coding-agent-practices/inventory.mjs @@ -379,7 +379,7 @@ async function collectCodexMemories(scope) { } function makeSessionSourceHints(scope) { - if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"].includes(scope.platform)) { return []; } return [ @@ -450,6 +450,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"], }; } @@ -538,7 +539,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; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome ?? inventory.piHome ?? inventory.kimiHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -643,6 +644,7 @@ export async function collectProviderInventory(options = {}) { qwenHome: scope.qwenHome, copilotHome: scope.copilotHome, piHome: scope.piHome, + kimiHome: scope.kimiHome, includeUserHome: scope.includeUserHome, includeGlobalHooks: scope.includeGlobalHooks, }); @@ -894,12 +896,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] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot|pi|kimi] [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 @@ -907,6 +909,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 `; @@ -921,9 +924,9 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const platform = options.platform ?? command ?? "qoder"; - if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi", "kimi"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi, kimi.\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 37fe8a7..4274b98 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"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"]); 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 b36eb86..b2f1363 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, or pi (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, or kimi (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 --canvas-out With Qoder, initialize canvas.json from lead facts --replace-canvas Replace that canvas.json when explicitly authorized --format json JSON only @@ -33,7 +34,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", "claude-state", "help", "h", + "cursor-home", "qwen-home", "copilot-home", "pi-home", "kimi-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 37aabb7..a22ff2a 100644 --- a/scripts/harness-analysis/evidence-bundle/contract.mjs +++ b/scripts/harness-analysis/evidence-bundle/contract.mjs @@ -8,7 +8,7 @@ export const EVIDENCE_LANE_NAMES = Object.freeze([ "agentCustomize", ]); -const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"]); const DEPTHS = new Set(["quick", "normal"]); function enabled(value) { diff --git a/scripts/harness-analysis/report-quality.mjs b/scripts/harness-analysis/report-quality.mjs index 7a6faab..0486a59 100644 --- a/scripts/harness-analysis/report-quality.mjs +++ b/scripts/harness-analysis/report-quality.mjs @@ -132,7 +132,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|qoder|codex|claude|cursor|qwen|copilot|\bpi\b|session-analysis|session sources|session evidence|会话分析|会话证据)/i; +const AI_PRACTICE_SESSION_SCOPE_RE = /(?:\.qoder|\.codex|\.claude|\.cursor|\.qwen|\.copilot|\.pi\b|\.kimi-code|\.kimi|qoder|codex|claude|cursor|qwen|copilot|\bpi\b|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 39aeae6..db997a7 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, or pi (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, pi, or kimi (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"]; +const REPORT_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"]; 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", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", "pi-home", "kimi-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); @@ -169,6 +169,7 @@ export async function analyzeHarnessEvidence(options = {}) { cursorHome: options["cursor-home"], qwenHome: options["qwen-home"], piHome: options["pi-home"], + kimiHome: options["kimi-home"], })).source; const sourceErrors = validateHarnessReportSource(source); if (sourceErrors.length > 0) { diff --git a/scripts/harness-analysis/task-loop-report.mjs b/scripts/harness-analysis/task-loop-report.mjs index 6c471c5..83a3db4 100644 --- a/scripts/harness-analysis/task-loop-report.mjs +++ b/scripts/harness-analysis/task-loop-report.mjs @@ -1509,7 +1509,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"].includes(source?.manifest?.scope?.platform) + const platform = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"].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 b3c823e..1a55c5f 100644 --- a/scripts/harness-analysis/task-loop-source.mjs +++ b/scripts/harness-analysis/task-loop-source.mjs @@ -66,13 +66,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, or Pi sessions. It retains privacy-safe episode, change, validation, +Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or Kimi 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) @@ -813,7 +813,7 @@ export function buildTaskLoopSourceCandidate({ export async function collectAgentLintPracticeEvidence(options = {}) { const provider = options.platform ?? "qoder"; - const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"].includes(provider); const common = { workspace: options.workspace, provider, @@ -824,6 +824,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"], }; const [instructionReview, assetReview, practiceInventory] = await Promise.all([ runAgentLint({ ...common, profile: "agents-md-review" }), @@ -931,6 +932,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"], + }); + } return Promise.resolve(null); } @@ -1046,6 +1057,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"], includeGlobalCapabilities: options.includeGlobalCapabilities ?? options["include-global-capabilities"] ?? false, diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 21db852..24ffbf6 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", @@ -337,6 +339,7 @@ const forbiddenBundlePrefixes = [ ".codex-plugin/", ".cursor-plugin/", ".github/plugin/", + ".kimi-plugin/", "qwen-extension.json", "prompts/", "test/", diff --git a/scripts/session-analysis.mjs b/scripts/session-analysis.mjs index 2ea2bc6..e190bbb 100644 --- a/scripts/session-analysis.mjs +++ b/scripts/session-analysis.mjs @@ -245,7 +245,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi.`); + if (platform === "kimi") { + const module = await import("./session-analysis/platforms/kimi.mjs"); + return { + Analyzer: module.KimiSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi, kimi.`); } export async function createAnalyzer(platform = "qoder") { @@ -295,7 +302,7 @@ export async function main(argv = process.argv.slice(2)) { ] : []; process.stdout.write([ - `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, + `Usage: session-analysis${command ? ` ${command}` : " "} --platform --workspace [options]`, "", "Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets", ...factsOptions, diff --git a/scripts/session-analysis/analyzer.mjs b/scripts/session-analysis/analyzer.mjs index 45a5f35..6c605e5 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -19,8 +19,9 @@ 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, or Pi session evidence. The -default command is sessions and the default platform is qoder. Help exits before +Inspect local Qoder, Codex, Claude, Cursor, Qwen, Copilot, Pi, or Kimi session +evidence. The default command is sessions and the default platform is qoder. +Help exits before reading HOME or workspace. Commands: @@ -35,7 +36,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 +46,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) --include-cache Include optional Qoder cache evidence --include-global-capabilities Include optional user-global Qoder evidence @@ -281,7 +283,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi.`); + if (platform === "kimi") { + const module = await import("./platforms/kimi.mjs"); + return { + Analyzer: module.KimiSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi, kimi.`); } export async function createAnalyzer(platform = "qoder") { @@ -295,7 +304,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) { if (command === "claude-facets") { if (options.help === true) { stdout.write([ - "Usage: session-analysis claude-facets --platform --workspace [options]", + "Usage: session-analysis claude-facets --platform --workspace [options]", "", "Options:", " --limit <1-5> Maximum semantic facets (default: 5)", diff --git a/scripts/session-analysis/fs.mjs b/scripts/session-analysis/fs.mjs index cec04f1..638d186 100644 --- a/scripts/session-analysis/fs.mjs +++ b/scripts/session-analysis/fs.mjs @@ -1,5 +1,5 @@ import { createReadStream } from "node:fs"; -import { readdir, stat } from "node:fs/promises"; +import { realpath, readdir, stat } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; @@ -56,7 +56,9 @@ export async function forEachJsonLine(filePath, onRecord, options = {}) { export async function walkFiles(root, options = {}) { const maxDepth = options.maxDepth ?? Infinity; const limit = options.limit ?? Infinity; + const followSymlinks = Boolean(options.followSymlinks); const files = []; + const visitedDirs = new Set(); async function visit(dir, depth) { if (files.length >= limit || depth > maxDepth) { @@ -77,13 +79,49 @@ export async function walkFiles(root, options = {}) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await visit(fullPath, depth + 1); - } else if (entry.isFile() && (!options.match || options.match(fullPath))) { - files.push(fullPath); + } else if (entry.isFile()) { + if (!options.match || options.match(fullPath)) { + files.push(fullPath); + } + } else if (followSymlinks && entry.isSymbolicLink()) { + // A symlink (including a Windows junction) Dirent is neither file nor + // directory, so stat the target to resolve its type. Stat failures + // (broken links, etc.) are silently skipped. + let targetStat; + try { + targetStat = await stat(fullPath); + } catch { + continue; + } + if (targetStat.isDirectory()) { + // Track visited directories by realpath so symlink loops cannot + // recurse forever. + let real; + try { + real = await realpath(fullPath); + } catch { + continue; + } + if (visitedDirs.has(real)) { + continue; + } + visitedDirs.add(real); + await visit(fullPath, depth + 1); + } else if (targetStat.isFile() && (!options.match || options.match(fullPath))) { + files.push(fullPath); + } } } } if (await isDirectory(root)) { + if (followSymlinks) { + try { + visitedDirs.add(await realpath(root)); + } catch { + // A realpath failure does not affect the traversal itself. + } + } await visit(root, 0); } return files; diff --git a/scripts/session-analysis/lifecycle-demand-signals.mjs b/scripts/session-analysis/lifecycle-demand-signals.mjs index a2e99fd..73a3a62 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"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"].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 ba0f315..291f18f 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 459c378..406ef4f 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 862749d..9fdf275 100644 --- a/templates/reporting/routing.md +++ b/templates/reporting/routing.md @@ -28,6 +28,6 @@ files from other routes. | Route | Use when | Artifacts | Runtime owner | | --- | --- | --- | --- | | Qoder Canvas report | Active host is Qoder | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `qoder-canvas.md` | -| Portable HTML report | Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi, 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, Cursor, Qwen Code, GitHub Copilot, Pi, or Kimi Code, 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 4208704..aacd600 100644 --- a/test/agent-asset-baseline.test.mjs +++ b/test/agent-asset-baseline.test.mjs @@ -319,3 +319,34 @@ test("Pi asset baseline completes from a native project fixture", async () => { await rm(root, { recursive: true, force: true }); } }); + +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 }); + } +}); diff --git a/test/agent-customize-architecture.test.mjs b/test/agent-customize-architecture.test.mjs index f0fcee2..b1d1434 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 \| Analysis-capable source-local host[^\n]+platforms\/cursor\.mjs/u); assert.doesNotMatch(adapterReadme, /Cursor has no session-evidence adapter/u); @@ -71,12 +73,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 3d53828..d69bea0 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -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"; @@ -1479,6 +1480,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 5f877fb..6e3a0e9 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -216,3 +216,26 @@ test("Pi agentCustomize lane routes the provider and isolated config paths", asy assert.equal(received["pi-home"], "/tmp/fixture-pi-home"); 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); +}); diff --git a/test/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index e000768..32b77d6 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -77,7 +77,7 @@ test("non-Qoder providers default to validated durable HTML with an explicit inl assert.match(skill, /HTML artifacts: findings\.json, report\.md, report\.html/); assert.match(skill, /Succeed only on\s+`status: pass`/); assert.match(skill, /Never hand-write\s+Canvas, Markdown, or HTML/); - assert.match(routing, /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi/); + assert.match(routing, /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, Pi, or Kimi Code/); assert.match(routing, /Inline only \| Inline or no-files output is explicitly requested \| none; inline analysis writes nothing/); assert.match(adapters, /Claude Code[^\n]+scripts\/session-analysis\/platforms\/claude\.mjs[^\n]+self-contained HTML \+ Markdown/); assert.match(adapters, /Cursor[^\n]+scripts\/session-analysis\/platforms\/cursor\.mjs[^\n]+self-contained HTML \+ Markdown/); diff --git a/test/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index e179931..edd8bf6 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -45,7 +45,7 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.qwen/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.copilot/session-state", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.pi/agent/sessions", "## Platform Notes", "Sessions Diagnostics"); - assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, and `pi`/); + assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, `pi`, and `kimi`/); 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 0011d56..5d94ffd 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -21,7 +21,7 @@ Commands: Project Evidence session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, - and Pi session evidence + Pi, and Kimi 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 ad303e8..ab3ac90 100644 --- a/test/fixtures/scripts-refactor-contract/session-help.txt +++ b/test/fixtures/scripts-refactor-contract/session-help.txt @@ -1,4 +1,4 @@ -Usage: session-analysis --platform --workspace [options] +Usage: session-analysis --platform --workspace [options] Commands: sources, sessions, facets, insights, facts, file-reads, show, events, claude-facets 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 c69e4b6..66b334b 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", @@ -290,6 +304,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); @@ -300,6 +315,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 6d4ebdf..5133ec7 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -70,12 +70,12 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "9e427d114cbd997e58391a8c376ae81733af09399a81fe55b44b1a765d2d879d", + sha256: "240ff52fece335db98aaf90011808255c0aeed190db6688c7fee3a0c0e1f2b03", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "f3551bbabff07a097d116faf4d349ab4da9596a0b3ab1dc09ff6b096657bb897", + sha256: "8b01673def1d3f7c085bbb3830019142e4b98b28ae5a46add0f89cbcdc813bb9", }, { 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..7cbf7ff --- /dev/null +++ b/test/session-analysis-fs.test.mjs @@ -0,0 +1,98 @@ +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(); + assert.deepEqual(names, ["linked-skill", "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 e29c9fa..ac49aaf 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -26,6 +26,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) { @@ -794,6 +795,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"); @@ -1010,3 +1161,188 @@ test("Pi custom session roots require a directory", async () => { assert.equal(result.sources[0].exists, false); 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); +}); diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index c96126c..ce76a8d 100644 --- a/test/style-templates.test.mjs +++ b/test/style-templates.test.mjs @@ -75,7 +75,7 @@ test("harness report routing owns output-mode selection and exclusions", () => { assert.match(reportRouting, /Choose exactly one output route/); assert.match(reportRouting, /Qoder Canvas report/); assert.match(reportRouting, /Portable HTML report/); - assert.match(reportRouting, /Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi/); + assert.match(reportRouting, /Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, Pi, or Kimi Code/); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); assert.match(reportRouting, /inline analysis writes nothing/); diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index 3ea3655..70df18f 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"]; +const SUPPORTED_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi", "kimi"]; const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); const adapterMatrixPath = path.join(process.cwd(), "docs", "adapters", "README.md");