From af5a6c607e34365892ef998e28761585838a19db Mon Sep 17 00:00:00 2001 From: linmaog <12575858+linmaog@user.noreply.gitee.com> Date: Thu, 30 Jul 2026 10:36:03 +0800 Subject: [PATCH] feat(hosts): add Pi coding agent host adapter Add Pi (pi.dev / @earendil-works/pi-coding-agent) as an analysis-capable source-local host, following the host contribution guide and the Qwen Code / GitHub Copilot host template. Native contracts were verified against @earendil-works/pi-coding-agent; a dated spec lives at docs/specs/2026-07-30-pi-host-support.md (pi-host-support) with stable PHS-AC acceptance ids. - Distribution shell: `pi` manifest in package.json plus a /better-harness prompt template, so `pi install ` discovers the canonical skills/ root and registers the slash command. Pi reuses the existing package.json, so the package still ships six host metadata roots and the Qoder runtime bundle stays Qoder-only. - Configured assets: scripts/agent-customize/providers/pi.mjs inventories settings-declared pi packages, extensions, skills, prompt templates, and AGENTS.md context. It honors Pi's effective state -- autoload:false fails closed, and per-resource allowlists, `!` exclusions, and `+`/`-` overrides narrow reported resources -- and models piHome and the real user home independently so ~/.agents/skills is found under a relocated PI_CODING_AGENT_DIR. - Session evidence: scripts/session-analysis/platforms/pi.mjs reads workspace-matching JSONL v3 transcripts, resolves the session directory as CLI > env > settings > default, treats a custom session directory as the exact flat JSONL directory qualified by the session-header cwd, gates default-tree root existence on a workspace-keyed directory, and keeps partial/malformed usage explicit instead of zero-filling. - Register `pi` across the supported-platform set and --pi-home threading; the A-06 consistency test now covers seven hosts. - Sync the host adapter matrix, site docs (en/zh), references, READMEs, and CHANGELOG; add provider, session, autoload/filter, relocated-home, custom-dir, precedence, usage, and prompt-template expansion tests. Validated with npm run check (896 tests, pack verification) and against real local Pi data. Co-authored-by: QoderAI (Pi) --- CHANGELOG.md | 19 +- README.md | 33 +- README.zh-CN.md | 28 +- docs/adapters/README.md | 27 +- docs/adrs/directory-structure.md | 2 +- docs/community.md | 2 +- docs/concepts.md | 2 +- docs/docs/hosts/adapter-matrix.md | 7 +- docs/glossary.md | 4 +- .../current/hosts/adapter-matrix.md | 7 +- docs/specs/2026-07-30-pi-host-support.md | 162 +++++++ package.json | 8 +- prompts/better-harness.md | 19 + references/agent-customize/README.md | 2 +- references/agent-customize/platforms/pi.md | 88 ++++ references/agent-customize/routing.md | 29 ++ .../session-evidence/sessions-diagnostics.md | 35 +- scripts/agent-customize/cli.mjs | 6 +- scripts/agent-customize/providers/index.mjs | 2 + scripts/agent-customize/providers/pi.mjs | 409 ++++++++++++++++ scripts/agent-lint/cli.mjs | 2 +- scripts/better-harness-cli/registry.mjs | 2 +- .../coding-agent-practices/asset-baseline.mjs | 6 +- .../asset-integrity.mjs | 6 +- scripts/coding-agent-practices/inventory.mjs | 14 +- .../evidence-bundle/agent-customize.mjs | 2 +- .../harness-analysis/evidence-bundle/cli.mjs | 4 +- .../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 | 17 +- scripts/npm-package/verify-pack.mjs | 2 + scripts/session-analysis.mjs | 11 +- scripts/session-analysis/analyzer.mjs | 15 +- .../lifecycle-demand-signals.mjs | 2 +- scripts/session-analysis/platforms/pi.mjs | 441 ++++++++++++++++++ .../session-analysis/selection-profile.mjs | 2 +- scripts/session-analysis/usage-summary.mjs | 2 +- test/agent-asset-baseline.test.mjs | 31 ++ test/agent-customize-architecture.test.mjs | 2 +- test/agent-customize.test.mjs | 207 ++++++++ test/better-harness-evidence-bundle.test.mjs | 23 + test/coding-agent-platform-notes.test.mjs | 3 +- .../scripts-refactor-contract/root-help.txt | 4 +- .../session-help.txt | 2 +- test/pi-prompt-template.test.mjs | 53 +++ test/plugin-manifests.test.mjs | 5 + test/scripts-refactor-contract.test.mjs | 4 +- test/session-analysis-providers.test.mjs | 251 ++++++++++ test/support-declarations.test.mjs | 2 +- 51 files changed, 1944 insertions(+), 75 deletions(-) create mode 100644 docs/specs/2026-07-30-pi-host-support.md create mode 100644 prompts/better-harness.md create mode 100644 references/agent-customize/platforms/pi.md create mode 100644 scripts/agent-customize/providers/pi.mjs create mode 100644 scripts/session-analysis/platforms/pi.mjs create mode 100644 test/pi-prompt-template.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6a1ac..fddae48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,25 @@ observable behavior and compatibility, not every internal refactor. ## Unreleased +### Added + +- 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, + and gains a Pi configured-asset provider (settings-declared pi packages, + skills, prompt templates, extensions, and `AGENTS.md` context) plus a Pi + session-evidence adapter that reads workspace-matching JSONL v3 transcripts + under `~/.pi/agent/sessions/` with `PI_CODING_AGENT_DIR` and + `PI_CODING_AGENT_SESSION_DIR` overrides. Pi's shell is the `pi` manifest in + the existing `package.json`, so the public npm package still ships six host + metadata roots and the Qoder runtime bundle remains Qoder-specific. + ### Changed - The `harness analyze` platform gate now names the full supported set - (`qoder, codex, claude, cursor, qwen, copilot`) when it rejects an unsupported - `--platform`, matching the session-analysis and asset-baseline gates. The - existing error prefix and exit behavior are unchanged. + (`qoder, codex, claude, cursor, qwen, copilot, pi`) when it rejects an + unsupported `--platform`, matching the session-analysis and asset-baseline + gates. The existing error prefix and exit behavior are unchanged. ## 0.3.0 - 2026-07-27 diff --git a/README.md b/README.md index 6040624..b1e6765 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Pick your coding agent — you can be looking at your first report in minutes: | **Qoder Desktop / CLI** | Nothing to install when Qoder Desktop is installed — Better Harness is built in and available to both. Open your repository and use the report prompt below. | | **GitHub Copilot CLI** | Add the repository marketplace, install `better-harness@better-harness`, start a new session, then use the report prompt below. | | **Cursor** | Load the plugin from source — see [Installation](#installation). | +| **Pi** | Install the repository as a pi package: `pi install https://github.com/QoderAI/better-harness`, start a new session, then use `/better-harness` or `/skill:better-harness`. | Once installed, ask Better Harness to generate the host's durable report: @@ -162,8 +163,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 Pi 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. @@ -336,6 +337,34 @@ transcripts under `~/.copilot/session-state/`. Copilot records no per-response token usage, and VS Code Copilot Chat has no supported durable transcript; both remain explicit evidence boundaries. +### Pi + +Install the repository as a [pi package](https://pi.dev/docs/latest/packages): + +```bash +pi install https://github.com/QoderAI/better-harness +``` + +Or try it for a single run without changing settings: + +```bash +pi -e git:github.com/QoderAI/better-harness +``` + +Pi discovers the `better-harness` skill and the `/better-harness` prompt +template through the `pi` manifest in `package.json`. Start a new Pi session +in the repository you want to review and run the report prompt: + +```text +/better-harness review this project's AI coding workflow and generate a report +``` + +Pi defaults to a self-contained `report.html` with paired `report.md` and +`findings.json` under the repository's `.pi/better-harness` report root. Pi +session evidence is read from workspace-matching JSONL transcripts under +`~/.pi/agent/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 4eedf1a..955cab8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,7 @@ Better Harness 开放了三个相互关联的层次,而不只是一个斜杠 | **Qoder Desktop / CLI** | 安装 Qoder Desktop 后无需额外安装——Better Harness 已内置,并可在桌面端和 CLI 中使用。打开仓库并使用下方的报告提示词。 | | **GitHub Copilot CLI** | 添加本仓库 Marketplace,安装 `better-harness@better-harness`,启动新会话,然后使用下方的报告提示词。 | | **Cursor** | 从源码加载插件——参见[安装](#installation)。 | +| **Pi** | 以 pi package 安装本仓库:`pi install https://github.com/QoderAI/better-harness`,启动新会话,然后使用 `/better-harness` 或 `/skill:better-harness`。 | 安装完成后,让 Better Harness 生成当前宿主支持的持久化报告: @@ -155,7 +156,7 @@ Better Harness 开放了三个相互关联的层次,而不只是一个斜杠 ``` 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 和 Pi 生成自包含的 HTML 报告及配套 Markdown。 缺失或不完整的证据会被明确标注。有关当前覆盖范围和输出差异,请参阅 [宿主适配器矩阵](docs/adapters/README.md)。 @@ -319,6 +320,31 @@ Copilot 会话证据来自 `~/.copilot/session-state/` 下与工作区匹配的 Copilot 不记录逐次响应的 token 用量,VS Code Copilot Chat 也没有受支持的持久化会话记录; 两者均作为明确的证据边界保留。 +### Pi + +将本仓库作为 [pi package](https://pi.dev/docs/latest/packages) 安装: + +```bash +pi install https://github.com/QoderAI/better-harness +``` + +或在不修改设置的情况下单次试用: + +```bash +pi -e git:github.com/QoderAI/better-harness +``` + +Pi 通过 `package.json` 中的 `pi` manifest 发现 `better-harness` 技能和 +`/better-harness` 提示模板。在需要审查的仓库中启动新的 Pi 会话,运行报告提示词: + +```text +/better-harness 审查此项目的 AI 编码工作流并生成报告 +``` + +Pi 默认在仓库的 `.pi/better-harness` 报告根目录下生成自包含的 `report.html` +及配套的 `report.md` 和 `findings.json`。Pi 会话证据读自 +`~/.pi/agent/sessions/` 下与工作区匹配的 JSONL 会话记录;缺失的证据会被明确标注而不会被推断。 + ## 从源码开发和打包 diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 2a89950..cc74dc1 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -1,9 +1,9 @@ # Host Adapter Matrix -This is the single entry point for Claude Code, Codex, Qoder, Cursor, Qwen, and -GitHub Copilot host boundaries. Do not create `docs/adapters/claude-code.md`, +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`, or `docs/adapters/copilot.md` by default. +`docs/adapters/qwen.md`, `docs/adapters/copilot.md`, or `docs/adapters/pi.md` by default. Adding another host? Follow [Contributing a New Coding Agent Host](contributing-new-coding-agent.md) before @@ -17,10 +17,11 @@ judgment stays in `skills/`, `models/`, `references/`, `templates/`, and `scripts//`. The `@qoderai/better-harness` npm package includes the Qoder, Claude Code, -Codex, Cursor, Qwen, and GitHub Copilot plugin metadata roots. The generated +Codex, Cursor, Qwen, Copilot, and Pi plugin metadata roots. 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. +through the repository's native marketplace manifest. Pi installs the repository +as a pi package through the `pi` manifest in `package.json`. | Host | Positioning | Shell | Configured Assets | Session Evidence | Default Output | Rules / Prompts | Smoke | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -31,6 +32,8 @@ through the repository's native marketplace manifest. | 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 | + ## Discovery And Evidence - Claude Code discovers the canonical root `skills/` directory through @@ -79,13 +82,25 @@ through the repository's native marketplace manifest. `.github/plugin/` shell is native Copilot install/discovery metadata included in the public npm package; it does not own Copilot evidence collection. +- Pi configured assets are inventoried through + `scripts/agent-customize/providers/pi.mjs`, covering `~/.pi/agent` + (settings-declared pi packages, skills, prompt templates, extensions, the + global `AGENTS.md` context file), the shared `.agents/skills` directories, + and project `.pi` assets. Session evidence comes from + `scripts/session-analysis/platforms/pi.mjs`, which reads workspace-matching + JSONL transcripts under `~/.pi/agent/sessions/----/` and honors the + `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` overrides. Pi + 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. + ## Output Modes 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 visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi 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 0062d13..7820e93 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 host adapters + platforms/.mjs # Qoder/Codex/Claude/Cursor/Qwen/Copilot/Pi host adapters ides// # target editor-local evidence not covered by host adapters / # [target] new capability owner cli.mjs # use cli.mjs for new capabilities diff --git a/docs/community.md b/docs/community.md index 1ad8061..bc8a419 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`, 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`, 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 | ## Non-Extension Boundaries diff --git a/docs/concepts.md b/docs/concepts.md index 7f7467a..26bdaac 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, or Copilot session behavior | +| Session evidence | `better-harness session-analysis` | Normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, or Pi 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/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 273f395..db6c2e6 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -19,8 +19,11 @@ host-neutral. | Claude Code | Analysis-capable source-local host | `.claude-plugin/` | Workspace-matching local Claude transcripts when present | Self-contained HTML + Markdown | | Codex | Analysis-capable source-local host | `.codex-plugin/` | Codex sessions | Self-contained HTML + Markdown | | Cursor | Analysis-capable source-local host | `.cursor-plugin/` | Workspace-matched transcripts, metadata, and audit logs; partial coverage stays explicit | Self-contained HTML + Markdown | +| Qwen Code | Analysis-capable source-local host | `qwen-extension.json` | Workspace-matching JSONL transcripts under `~/.qwen/projects//chats/` | Self-contained HTML + Markdown | +| GitHub Copilot | Analysis-capable source-local host | `.github/plugin/` | Workspace-matching transcripts under `~/.copilot/session-state//events.jsonl` | Self-contained HTML + Markdown | +| Pi | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching JSONL transcripts under `~/.pi/agent/sessions/` | Self-contained HTML + Markdown | -The `@qoderai/better-harness` npm package includes all four plugin metadata +The `@qoderai/better-harness` npm package includes all six plugin metadata roots. The generated Qoder runtime bundle includes only the Qoder shell; non-Qoder generated host artifacts remain source-local. @@ -28,7 +31,7 @@ non-Qoder generated host artifacts remain source-local. - **Qoder Canvas** — renderer-owned `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. -- **HTML visual** — portable Claude Code/Codex/Cursor contract covering +- **HTML visual** — portable Claude Code/Codex/Cursor/Qwen/Copilot/Pi contract covering `findings.json`, `report.md`, and a self-contained `report.html` (see the [live demo](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. diff --git a/docs/glossary.md b/docs/glossary.md index 0e885de..3643099 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, or Copilot agent session behavior into evidence. | [scripts/session-analysis](../scripts/session-analysis) | +| `session-analysis` | Normalizes Qoder, Codex, Claude, Cursor, Qwen, Copilot, or Pi 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`, 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`, 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) | | 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/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index 0f93004..b1db221 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -18,15 +18,18 @@ Better Harness 运行在你现有的编码智能体内。宿主差异只进入 | Claude Code | 具备分析能力的源码本地宿主 | `.claude-plugin/` | 匹配当前工作区的本地 Claude 转录(存在时) | 自包含 HTML + Markdown | | Codex | 具备分析能力的源码本地宿主 | `.codex-plugin/` | Codex 会话 | 自包含 HTML + Markdown | | Cursor | 具备分析能力的源码本地宿主 | `.cursor-plugin/` | 工作区匹配的转录、元数据和审计日志;部分覆盖保持显式标注 | 自包含 HTML + Markdown | +| Qwen Code | 具备分析能力的源码本地宿主 | `qwen-extension.json` | `~/.qwen/projects//chats/` 下工作区匹配的 JSONL 转录 | 自包含 HTML + Markdown | +| GitHub Copilot | 具备分析能力的源码本地宿主 | `.github/plugin/` | `~/.copilot/session-state//events.jsonl` 下工作区匹配的转录 | 自包含 HTML + Markdown | +| Pi | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | `~/.pi/agent/sessions/` 下工作区匹配的 JSONL 转录 | 自包含 HTML + Markdown | -`@qoderai/better-harness` npm 包含全部四个插件元数据根目录。生成的 Qoder +`@qoderai/better-harness` npm 包含全部六个插件元数据根目录。生成的 Qoder 运行时 bundle 只包含 Qoder shell;非 Qoder 的生成宿主产物保持源码本地。 ## 输出模式 - **Qoder Canvas** —— 渲染器负责的 `findings.json`、仅 Canvas 使用的 `canvas.json` 和 `report.canvas.tsx`。 -- **HTML 可视化** —— 面向 Claude Code/Codex/Cursor 的可移植契约,覆盖 +- **HTML 可视化** —— 面向 Claude Code/Codex/Cursor/Qwen/Copilot/Pi 的可移植契约,覆盖 `findings.json`、`report.md` 和自包含的 `report.html` (见[在线 Demo](pathname:///demo/better-harness-report/))。 - **纯 Markdown** —— 无视觉版本。 diff --git a/docs/specs/2026-07-30-pi-host-support.md b/docs/specs/2026-07-30-pi-host-support.md new file mode 100644 index 0000000..d2313e0 --- /dev/null +++ b/docs/specs/2026-07-30-pi-host-support.md @@ -0,0 +1,162 @@ +# Add Pi as a supported host + +## Traceability + +- Spec ID: `pi-host-support` +- Status: Implemented +- Contribution workflow: [Contributing a New Coding Agent Host](../adapters/contributing-new-coding-agent.md) + +## Intent + +Make Pi (`pi.dev`, `@earendil-works/pi-coding-agent`) a first-class, +evidence-safe Better Harness host alongside Qoder, Codex, Claude Code, Cursor, +Qwen Code, and GitHub Copilot. + +Pi is a minimal terminal coding harness extended through TypeScript extensions, +skills, prompt templates, themes, and pi packages. It already implements the +Agent Skills standard and auto-discovers a package's `skills/` directory, so +`pi install ` can load the canonical `better-harness` Skill today. The +gaps this spec closes are the missing native slash-command entry point and, +more importantly, the missing evidence: every provider enum rejected `pi`, so +the workflow could neither inventory Pi's configured assets nor read Pi session +transcripts, leaving a Pi user unable to get a Pi-scoped Harness report. + +Pi's native contracts were verified against `@earendil-works/pi-coding-agent`: +the agent dir resolves from `PI_CODING_AGENT_DIR` (default `~/.pi/agent`); +sessions are JSONL trees whose default location is +`~/.pi/agent/sessions/----/_.jsonl`, where the slug +strips one leading separator and replaces `/`, `\`, and `:` with `-`; and +`--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, and the settings `sessionDir` +key name the exact flat directory that contains JSONL files rather than a +cwd-keyed parent. Sessions use the version-3 header carrying `cwd` and the +session id, `message` entries for user/assistant/toolResult, `toolCall` content +blocks, and a finite `message.usage` object. + +## Acceptance Scenarios + +- **PHS-AC-1 (native shell):** The repository declares a `pi` manifest in + `package.json` (`pi.skills`, `pi.prompts`) and the `pi-package` keyword, so + `pi install ` and `pi -e ` discover the canonical root `skills/` + Skill and the `prompts/` templates. The manifest ships in the public npm + package; the Qoder runtime bundle stays Qoder-specific. Pi reuses the + existing `package.json`, so it adds no new standalone metadata root and the + package still ships six host metadata roots. +- **PHS-AC-2 (native slash command):** `prompts/better-harness.md` registers a + `/better-harness` prompt template that routes to the `better-harness` Skill, + supplies the `pi` host context, and expands arguments through the supported + `${@:-default}` form. +- **PHS-AC-3 (configured assets):** `agent-customize` supports `--provider pi` + through a capability-owned provider module that inventories settings-declared + pi packages (npm/git/local), loose extensions, user and project Skills, + prompt templates, and `AGENTS.md` context. The shared `.agents/skills` + standard directory is discovered from the real user home even when + `PI_CODING_AGENT_DIR` relocates the agent dir. +- **PHS-AC-4 (effective package state):** Package entries in object form honor + Pi's effective-state contract. `autoload: false` fails closed and publishes + no child resources; per-resource allowlists, `!` exclusions, `+`/`-` exact + overrides, and `[]` (load none) narrow the reported Skills and prompt + templates. A source declared in both project and user settings resolves to + the project entry. +- **PHS-AC-5 (session evidence):** `session-analysis` supports `--platform pi` + through a capability-owned platform module that reads workspace-matching + version-3 JSONL transcripts, verifies the session-header `cwd`, and resolves + the session directory as CLI over environment over settings over default. A + custom session directory is treated as the exact flat JSONL directory whose + files are still qualified by the session-header cwd; the default tree nests + transcripts under workspace-keyed `----` directories. +- **PHS-AC-6 (evidence boundaries):** Missing or malformed `message.usage` + fields stay absent rather than becoming zero, so partial per-response usage + is explicit. A parent session directory alone is not an evidence root: the + default tree requires at least one workspace-keyed session directory before + `sources` reports the root as present. Session ids and home paths never enter + production facts. +- **PHS-AC-7 (bundle propagation):** `harness evidence-bundle --platform pi` + freezes a Pi context and returns all three lanes, and `--pi-home` routes + isolated configuration paths into the Agent Customize lane through both the + collector API and the `agent-customize` CLI. +- **PHS-AC-8 (host routing):** The host adapter matrix carries a Pi row with + discovery paths, evidence sources, default output, and a smoke command. + Portable HTML routing includes Pi, and Qoder remains the only Canvas host. +- **PHS-AC-9 (support-declaration consistency):** `pi` 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). +- **PHS-AC-10 (provider behavior):** Deterministic fixtures cover Pi asset + inventory, autoload/filter effective state, relocated-agent-dir user-home + discovery, transcript normalization, custom-directory and precedence + resolution, foreign-workspace rejection, and partial/malformed usage. The + supported prompt-template argument syntax is exercised by an expansion smoke + test. +- **PHS-AC-11 (documentation integrity):** Markdown links and the generated + documentation routing graph remain current, and both READMEs document the + `pi install` path. + +## Non-goals + +- Add a native MCP inventory for Pi. Pi has no native MCP registry; MCP arrives + through extension packages, so MCP capability claims stay extension-bound. +- Expand pi manifest glob sources beyond declared resource directories, or + model per-resource enable state outside settings package entries. Both stay + declared unsupported rather than over-reporting resources Pi may not load. +- Inventory Pi themes or extension runtime registration state. +- Split a `docs/adapters/pi.md`; no split trigger is met. +- Add a `scripts/packaging/` Pi host-artifact builder. Pi installs from the + existing package manifest and needs no generated shell. +- Add Pi metadata to the Qoder runtime bundle. +- Treat configured Pi assets, zero candidates, or a loaded Skill as proof of + runtime quality or Skill invocation. + +## Plan and Tasks + +1. Add the `pi` manifest and `pi-package` keyword to `package.json`, ship + `prompts/` in the public package files list and packaging verifier, and add + `prompts/better-harness.md`. (PHS-AC-1, PHS-AC-2) +2. Add `scripts/agent-customize/providers/pi.mjs` as the capability owner and + register it in the provider registry, modeling `piHome` and the real user + home independently and applying Pi's autoload/filter effective state. + (PHS-AC-3, PHS-AC-4) +3. Add `scripts/session-analysis/platforms/pi.mjs` and register it in both the + public `session-analysis.mjs` dispatch and the capability-owned + `analyzer.mjs` dispatch, distinguishing the default cwd-keyed tree from a + custom flat session directory and keeping usage partial. (PHS-AC-5, + PHS-AC-6) +4. Register `pi` across the remaining provider enums and `--pi-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. (PHS-AC-7, PHS-AC-9) +5. Add `references/agent-customize/platforms/pi.md`, a Pi asset route, and a Pi + section in Session Diagnostics. (PHS-AC-3, PHS-AC-5) +6. Add the Pi host matrix row, keep the metadata-root counts across the + architecture, community, glossary, and concepts docs, and route Pi through + portable HTML reporting. (PHS-AC-8) +7. Document the `pi install` path in both READMEs. (PHS-AC-11) +8. Extend tests and packaging verification for the new manifest, provider, + platform, enums, and the supported-platform consistency set, plus an + expansion smoke test for the prompt template. (PHS-AC-9, PHS-AC-10, + PHS-AC-11) + +## Test and Review Evidence + +- `node --test` +- `node --test test/doc-link-graph.test.mjs` +- `npm run pack:verify` +- `pi install ` then `/better-harness` (or `/skill:better-harness`), + expecting the reported Skill and prompt discovery. +- `node scripts/better-harness.mjs harness evidence-bundle --platform pi + --workspace . --depth quick --format json` + +## Risk + +Pi's session format and directory contract are observed from +`@earendil-works/pi-coding-agent` rather than a published schema. The platform +module treats non-message entries as ignorable metadata and keeps coverage +explicit instead of inferring activity, so a Pi change surfaces as a failing +contract rather than silent misrouting. + +## Unknowns + +- [NEEDS CLARIFICATION: whether Pi publishes a versioned schema for the + session JSONL format that the platform test should validate against.] +- [NEEDS CLARIFICATION: whether Pi will expose an MCP registry natively, at + which point MCP inventory could move off the extension-bound boundary.] diff --git a/package.json b/package.json index 8ed00a8..a7ddafd 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,14 @@ "better-harness", "ai-delivery", "continuous-improvement", - "agent-harness" + "agent-harness", + "pi-package" ], "type": "module", + "pi": { + "skills": ["./skills"], + "prompts": ["./prompts"] + }, "files": [ ".claude-plugin/", ".codex-plugin/", @@ -51,6 +56,7 @@ "!docs/package-lock.json", "hooks/", "models/", + "prompts/", "references/", "scripts/", "!scripts/packaging/", diff --git a/prompts/better-harness.md b/prompts/better-harness.md new file mode 100644 index 0000000..318d8ea --- /dev/null +++ b/prompts/better-harness.md @@ -0,0 +1,19 @@ +--- +description: Review this project's AI coding workflow with Better Harness and generate a durable report. +argument-hint: [review request, e.g. "review this project's AI coding workflow and generate a report"] +--- + +Use the `better-harness` skill for this request. Read the skill's SKILL.md +first and follow its workflow exactly; do not improvise a substitute review. + +Host context for this run: + +- The current provider is `pi`. Use `--platform pi` for evidence commands. +- The durable output mode is self-contained HTML with paired Markdown + (`findings.json`, `report.md`, `report.html`) under `/.pi/better-harness`. +- Pi session evidence lives in `~/.pi/agent/sessions/`; the session-analysis + `pi` platform reads it. Missing or partial evidence stays explicit. + +User request: + +${@:-review this project's AI coding workflow and generate a report} diff --git a/references/agent-customize/README.md b/references/agent-customize/README.md index 92f31f4..3a1fb49 100644 --- a/references/agent-customize/README.md +++ b/references/agent-customize/README.md @@ -15,7 +15,7 @@ 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`, and `platforms/copilot.md`. + `platforms/qoder.md`, `platforms/qwen.md`, `platforms/copilot.md`, and `platforms/pi.md`. ## Does Not Own diff --git a/references/agent-customize/platforms/pi.md b/references/agent-customize/platforms/pi.md new file mode 100644 index 0000000..baa6d39 --- /dev/null +++ b/references/agent-customize/platforms/pi.md @@ -0,0 +1,88 @@ +# Pi Best Practices + +Use this file for Pi-specific operating practice. Use `../routing.md` for +host-neutral owner selection and `codex.md`/`claude.md`/`qwen.md` for other +host references. Do not copy Pi-only workflow advice into shared docs unless +there is matching surface evidence. + +## Operating Frame + +Treat Pi as a minimal, extensible harness rather than a fixed product. Start +with the right task context, move repeated guidance into `AGENTS.md`, +configure Pi through `settings.json`, turn repeated work into Skills and +prompt templates, package shareable workflows as pi packages, and extend +behavior through TypeScript extensions only when Markdown assets are not +enough. + +## Prompt Shape + +A strong first prompt has four parts: + +- **Goal**: the change, bug, review, artifact, or decision needed. +- **Context**: files, folders, docs, examples, logs, errors, or other material + Pi should inspect. +- **Constraints**: architecture rules, safety limits, review standards, + platform requirements, and do-not-touch boundaries. +- **Done when**: tests, checks, behavior, output files, or review evidence + that prove the task is complete. + +## Durable Guidance + +Use `AGENTS.md` for repository guidance that should load automatically. Pi +loads the global `~/.pi/agent/AGENTS.md` first, then `AGENTS.md` (or +`CLAUDE.md`) from the workspace and its ancestor directories: + +- repo layout and important directories +- build, test, lint, and local run commands +- engineering conventions and review expectations +- safety constraints and do-not rules +- what "done" means and how to verify work + +Keep context files short and practical. Put large or conditional detail in +linked references. Use project-level `.pi/settings.json` for repo-specific +defaults the team should share; pi installs missing declared packages on +startup after the project is trusted. + +## Configured Surfaces + +- **Skills**: `~/.pi/agent/skills/`, `~/.agents/skills/`, project + `.pi/skills/`, and `.agents/skills/` directories follow the Agent Skills + standard (`SKILL.md` with `name` and `description` frontmatter). Skills also + register as `/skill:` commands. +- **Prompt templates**: `.md` files under `~/.pi/agent/prompts/` and + `.pi/prompts/` register as slash commands named after the file; frontmatter + `description` and `argument-hint` shape discovery. Pi's `substituteArgs` + interpolates arguments through `$@` / `$ARGUMENTS` for all args, `$1`..`$N` + for positional args, and the default-value form `${@:-default}` / + `${ARGUMENTS:-default}` / `${1:-default}` that expands to the fallback when + no argument is supplied. The bundled `/better-harness` template relies on + the `${@:-...}` default form, verified against + `@earendil-works/pi-coding-agent`. +- **Extensions**: TypeScript/JavaScript modules under + `~/.pi/agent/extensions/` and `.pi/extensions/` register tools, commands, + event handlers, and custom UI. Extension code runs with full system access; + treat the inventory as metadata and never execute it during review. +- **Pi packages**: `settings.json` `packages` entries (npm, git, or local + paths) bundle extensions, skills, prompts, and themes. npm installs resolve + under `~/.pi/agent/npm/`, git clones under `~/.pi/agent/git//`, + and project-scoped installs under `.pi/npm/` and `.pi/git/`. A package + declares resources through the `pi` key in `package.json` or conventional + `skills/`, `prompts/`, `extensions/`, and `themes/` directories. + +## Session Evidence + +Pi sessions are JSONL trees under +`~/.pi/agent/sessions/----/_.jsonl` with a +version-3 header carrying `cwd` and the session id. `PI_CODING_AGENT_DIR` and +`PI_CODING_AGENT_SESSION_DIR` relocate the agent dir and session storage. +Route session reads through `scripts/session-analysis/platforms/pi.mjs`; +configured presence never substitutes for observed session behavior. + +## Boundaries + +- Pi has no native MCP registry; MCP support arrives through extension + packages. Bind MCP capability claims to extension evidence. +- Package manifest glob filters and per-resource enable/disable state are not + inventoried; keep those claims out of readiness evidence. +- Skills and extensions can instruct or execute arbitrary actions. Review + provenance before treating a configured asset as a safe, supported path. diff --git a/references/agent-customize/routing.md b/references/agent-customize/routing.md index f6a13cf..9a818c2 100644 --- a/references/agent-customize/routing.md +++ b/references/agent-customize/routing.md @@ -371,3 +371,32 @@ Use the Global/User Asset Pass from `global-assets.md` when the user asks about Copilot global assets such as `~/.copilot/skills` or `~/.copilot/hooks`, or installed plugins. Keep configured inventory evidence separate from observed session behavior. + +## Pi Asset Route + +For Pi-specific actions, use `platforms/pi.md` as the operating practice +reference for prompt shape, `AGENTS.md` context files, `.pi` config, skills, +prompt templates, extensions, and pi packages. Presence is not execution +proof. + +Inspect configured surfaces before projecting readiness evidence: + +- `AGENTS.md` (project and ancestors) and the global `~/.pi/agent/AGENTS.md` + context file for durable repo context. +- `.pi/settings.json` and `~/.pi/agent/settings.json` for default model, + thinking level, declared pi packages, and skill/prompt overrides. +- `.pi/skills`, `.agents/skills` (project and `~/.agents/skills`), and + `~/.pi/agent/skills` for repeatable workflows. +- `.pi/prompts` and `~/.pi/agent/prompts` for prompt templates that register + as slash commands. +- Pi packages declared in `settings.json` `packages` entries, resolved under + `~/.pi/agent/npm/` and `~/.pi/agent/git/` (or `.pi/npm/` and `.pi/git/` for + project installs), plus loose extensions under `~/.pi/agent/extensions/`. +- Session, diff, test, build, and review evidence for observed execution. + +Pi has no native MCP inventory; MCP arrives through extensions such as the +MCP adapter package, so keep MCP capability claims bound to extension +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. diff --git a/references/session-evidence/sessions-diagnostics.md b/references/session-evidence/sessions-diagnostics.md index 6cff846..1e0ff3a 100644 --- a/references/session-evidence/sessions-diagnostics.md +++ b/references/session-evidence/sessions-diagnostics.md @@ -12,22 +12,22 @@ fails validation: ```bash # Discover evidence roots for a workspace - scripts/session-analysis.mjs sources --platform --workspace /path/to/repo + scripts/session-analysis.mjs sources --platform --workspace /path/to/repo # Session list with event counts and time range - scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs facets --platform --workspace /path/to/repo --limit 20 # Compact insight cards and action candidates - scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 + scripts/session-analysis.mjs insights --platform --workspace /path/to/repo --limit 20 # Read single session events - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events # Diagnose the facts admission funnel and resolve candidate refs to local sessions - scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json + scripts/session-analysis.mjs facts --platform --workspace /path/to/repo --selection all-eligible --limit 5 --debug --output /tmp/session-facts-debug.json # Expand one debug locator with normalized commands and user text - scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text + scripts/session-analysis.mjs show --platform --workspace /path/to/repo --session-id --include-events --include-command-text --include-user-text ``` `facts --debug` is an operator-only diagnostic route. It exposes raw session @@ -38,7 +38,7 @@ opening only the candidate sessions needed to explain a surprising aggregate. Command and user text flags are also local-only and must not be used for broad transcript dumps. -Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, and `copilot`. Do not invent +Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, `copilot`, and `pi`. 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. @@ -286,3 +286,24 @@ output is undocumented and stays `unobserved`. Route configured Copilot rules (`AGENTS.md`, `.github/copilot-instructions.md`, `.github/instructions/`), Skills, Agents, hooks, MCP, and installed Plugins through `../agent-customize/global-assets.md`; configured presence does not prove use. + +### Pi + +For Pi, the analyzer reads workspace-matching JSONL transcripts under +`~/.pi/agent/sessions/----/*.jsonl` and verifies the `cwd` embedded +in the version-3 session header before admitting a session. Pi computes the +directory slug by stripping the leading separator and replacing path +separators with `-`, so `/home/admin/workspace` maps to +`~/.pi/agent/sessions/--home-admin-workspace--/`. The `PI_CODING_AGENT_DIR` +and `PI_CODING_AGENT_SESSION_DIR` environment variables relocate the agent +dir and session storage; honor them before assuming the default path. Pi does +not maintain a separate audit log; transcript `message` entries are the +primary lifecycle source, with tool calls carried in assistant `toolCall` +content blocks and tool results in `toolResult` role messages. + +Treat transcript timestamps, model usage (`message.usage`), tool calls, and +tool results as provider-labelled coverage. Entries such as `model_change`, +`compaction`, and `custom` stay metadata. Route configured Pi rules +(`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. diff --git a/scripts/agent-customize/cli.mjs b/scripts/agent-customize/cli.mjs index baf9edc..546eebf 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, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", + "--qwen-home, --copilot-home, --pi-home, --claude-state, --codex-app-path, --qoder-shared-client-cache-root.", "", ].join("\n"); } @@ -31,6 +31,7 @@ function summarize(inventory, options) { claudeHome: inventory.claudeHome, qwenHome: inventory.qwenHome, copilotHome: inventory.copilotHome, + piHome: inventory.piHome, claudeStatePath: inventory.claudeStatePath, codexAppPath: inventory.codexAppPath, sharedClientCacheRoot: inventory.sharedClientCacheRoot, @@ -74,6 +75,7 @@ async function main() { claudeHome: options["claude-home"], qwenHome: options["qwen-home"], copilotHome: options["copilot-home"], + piHome: options["pi-home"], claudeStatePath: options["claude-state"] ?? options["claude-state-path"], codexAppPath: options["codex-app-path"], qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"], diff --git a/scripts/agent-customize/providers/index.mjs b/scripts/agent-customize/providers/index.mjs index a000baa..67d4566 100644 --- a/scripts/agent-customize/providers/index.mjs +++ b/scripts/agent-customize/providers/index.mjs @@ -2,6 +2,7 @@ import { collectClaudeCustomizeInventory } from "./claude.mjs"; import { collectCodexCustomizeInventory } from "./codex.mjs"; import { collectCopilotCustomizeInventory } from "./copilot.mjs"; import { collectCursorCustomizeInventory } from "./cursor.mjs"; +import { collectPiCustomizeInventory } from "./pi.mjs"; import { collectQoderCustomizeInventory } from "./qoder.mjs"; import { collectQwenCustomizeInventory } from "./qwen.mjs"; @@ -12,6 +13,7 @@ export const PROVIDER_COLLECTORS = new Map([ ["claude", collectClaudeCustomizeInventory], ["qwen", collectQwenCustomizeInventory], ["copilot", collectCopilotCustomizeInventory], + ["pi", collectPiCustomizeInventory], ]); export async function collectProviderInventory(provider, options = {}) { diff --git a/scripts/agent-customize/providers/pi.mjs b/scripts/agent-customize/providers/pi.mjs new file mode 100644 index 0000000..a0e17cf --- /dev/null +++ b/scripts/agent-customize/providers/pi.mjs @@ -0,0 +1,409 @@ +import os from "node:os"; +import path from "node:path"; + +import { pathExists } from "../../session-analysis/fs.mjs"; +import { expandHome, normalizeWorkspace } from "../../session-analysis/paths.mjs"; +import { MANAGE_TABS } from "../constants.mjs"; +import { + agentsMarkdownRuleSource, + buildManageCollections, + collectMarkdownItems, + collectRuleSources, + collectSkillFiles, + collectWorkspaceRootPrimitives, + designMarkdownRuleSource, + evidence, + listDirectories, + normalizePluginDisplayName, + readJson, + readText, + sortByName, + titleCase, + workspaceSourceLabel, +} from "../core/items.mjs"; + +function defaultPiHome() { + // Pi resolves its agent dir from PI_CODING_AGENT_DIR (default ~/.pi/agent). + return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); +} + +function packageSourceString(entry) { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object" && typeof entry.source === "string") return entry.source; + return null; +} + +// Pi settings package entries are either a source string or an object form +// carrying autoload and per-resource filters. Keep the whole effective-state +// contract: autoload and filters decide what Pi actually loads. +function normalizePiPackageEntry(entry) { + const source = packageSourceString(entry); + if (!source) return null; + if (typeof entry === "string") { + return { source, autoload: true, filters: {} }; + } + return { + source, + autoload: entry.autoload !== false, + filters: { + skills: Array.isArray(entry.skills) ? entry.skills : undefined, + prompts: Array.isArray(entry.prompts) ? entry.prompts : undefined, + }, + }; +} + +function toPosixPath(value) { + return value.split(path.sep).join("/").replace(/^\.\//u, ""); +} + +function piGlobToRegExp(pattern) { + let out = ""; + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i]; + if (ch === "*") { + if (pattern[i + 1] === "*") { + out += ".*"; + i += 1; + if (pattern[i + 1] === "/") i += 1; + } else { + out += "[^/]*"; + } + } else if (ch === "?") { + out += "[^/]"; + } else { + out += ch.replace(/[.+^${}()|[\]\\]/gu, "\\$&"); + } + } + return new RegExp(`^${out}$`, "u"); +} + +function piItemCandidates(item, packageRoot) { + const rel = toPosixPath(path.relative(packageRoot, item.filePath)); + const parent = path.posix.dirname(rel); + return [rel, path.posix.basename(rel), parent, path.posix.basename(parent)].filter(Boolean); +} + +function matchesPiPattern(pattern, candidates) { + const normalized = toPosixPath(pattern); + const regex = piGlobToRegExp(normalized); + return candidates.some((candidate) => regex.test(candidate)); +} + +// Apply Pi's documented filter layering: omitted key loads all, [] loads +// none, positive patterns narrow, !pattern excludes, +path force-includes an +// exact path, and -path force-excludes an exact path. +function applyPiResourceFilter(items, packageRoot, filter) { + if (filter === undefined) return items; + if (filter.length === 0) return []; + const positives = filter.filter((p) => typeof p === "string" && !/^[!+-]/u.test(p)); + const excludes = filter.filter((p) => typeof p === "string" && p.startsWith("!")).map((p) => p.slice(1)); + const forceIncludes = filter.filter((p) => typeof p === "string" && p.startsWith("+")).map((p) => toPosixPath(p.slice(1))); + const forceExcludes = filter.filter((p) => typeof p === "string" && p.startsWith("-")).map((p) => toPosixPath(p.slice(1))); + return items.filter((item) => { + const candidates = piItemCandidates(item, packageRoot); + if (forceExcludes.some((exact) => candidates.includes(exact))) return false; + if (forceIncludes.some((exact) => candidates.includes(exact))) return true; + if (positives.length > 0 && !positives.some((pattern) => matchesPiPattern(pattern, candidates))) return false; + if (excludes.some((pattern) => matchesPiPattern(pattern, candidates))) return false; + return true; + }); +} + +// Manifest arrays are resource sources; plain directory entries are walked +// and `!` exclusions are honored as a filter layer. Glob source expansion +// beyond declared directories stays unexpanded (fail closed) rather than +// over-reporting resources Pi may not load. +function manifestExclusions(manifest, key) { + const declared = Array.isArray(manifest?.[key]) ? manifest[key] : []; + return declared.filter((entry) => typeof entry === "string" && entry.startsWith("!")).map((entry) => entry.slice(1)); +} + +function normalizeGitClonePath(source) { + let value = source; + if (value.startsWith("git:")) value = value.slice("git:".length); + value = value.replace(/^(?:https?|ssh|git):\/\//u, ""); + value = value.replace(/^git@([^:]+):/u, "$1/"); + const at = value.lastIndexOf("@"); + if (at > value.lastIndexOf("/")) value = value.slice(0, at); + value = value.replace(/\.git$/u, ""); + return value.split("/").filter(Boolean).join(path.sep); +} + +export function resolvePiPackageRecord(source, { scopeRoot, settingsDir, scope }) { + if (source.startsWith("npm:")) { + let spec = source.slice("npm:".length); + const versionAt = spec.lastIndexOf("@"); + if (versionAt > 0) spec = spec.slice(0, versionAt); + return { + id: `pi/${scope}/${source}`, + name: spec, + source, + sourceKind: "npm", + installPath: path.join(scopeRoot, "npm", "node_modules", ...spec.split("/")), + }; + } + if (source.startsWith("git:") || /^(?:https?|ssh|git):\/\//u.test(source)) { + const clonePath = normalizeGitClonePath(source); + return { + id: `pi/${scope}/${source}`, + name: path.basename(clonePath), + source, + sourceKind: "git", + installPath: path.join(scopeRoot, "git", clonePath), + }; + } + const localPath = path.isAbsolute(expandHome(source)) + ? path.resolve(expandHome(source)) + : path.resolve(settingsDir, source); + return { + id: `pi/${scope}/${source}`, + name: path.basename(localPath), + source, + sourceKind: "local", + installPath: localPath, + }; +} + +async function manifestResourceDirs(manifest, packageRoot, key, conventional) { + const declared = Array.isArray(manifest?.[key]) ? manifest[key] : null; + if (!declared) { + return [path.join(packageRoot, conventional)]; + } + const dirs = []; + for (const entry of declared) { + if (typeof entry !== "string" || entry.includes("*") || /^[!+-]/u.test(entry)) continue; + dirs.push(path.resolve(packageRoot, entry)); + } + return dirs; +} + +async function collectPiPackagePlugin(record, scope, entryConfig = { autoload: true, filters: {} }) { + const packageRoot = record.installPath; + if (!(await pathExists(packageRoot))) { + return null; + } + const packageJson = (await readJson(path.join(packageRoot, "package.json"))) ?? {}; + const manifest = packageJson.pi && typeof packageJson.pi === "object" ? packageJson.pi : null; + const readme = await readText(path.join(packageRoot, "README.md"), 6000); + const heading = readme.match(/^#\s+(.+)$/mu)?.[1]?.trim(); + const rawDisplayName = packageJson.displayName || heading || titleCase(packageJson.name || record.name); + const displayName = normalizePluginDisplayName(rawDisplayName, record.name); + const metadataPath = (await pathExists(path.join(packageRoot, "package.json"))) + ? path.join(packageRoot, "package.json") + : packageRoot; + const plugin = { + id: record.id, + piPackageSource: record.source, + rootPath: packageRoot, + scope: "plugin", + sourceLabel: displayName, + name: packageJson.name || record.name, + displayName, + description: packageJson.description || "", + publisher: { displayName: "Pi Package" }, + version: packageJson.version, + installSources: [scope], + installSource: scope, + installMatch: `pi-settings-${record.sourceKind}`, + installType: record.sourceKind, + enabled: entryConfig.autoload !== false, + evidence: evidence(metadataPath, path.dirname(packageRoot)), + }; + if (entryConfig.autoload === false) { + // Pi does not auto-load this package's resources; publishing them as + // enabled configured assets would overstate the effective state. + plugin.skills = []; + plugin.commands = []; + plugin.rules = []; + plugin.subagents = []; + plugin.hooks = []; + plugin.mcpServers = []; + plugin.resourceState = "not-autoloaded"; + return plugin; + } + const skillDirs = await manifestResourceDirs(manifest, packageRoot, "skills", "skills"); + const promptDirs = await manifestResourceDirs(manifest, packageRoot, "prompts", "prompts"); + const collectedSkills = (await Promise.all( + skillDirs.map((dir) => collectSkillFiles(dir, "plugin", displayName, packageRoot)), + )).flat(); + const collectedPrompts = (await Promise.all( + promptDirs.map((dir) => collectMarkdownItems(dir, "command", "plugin", displayName, packageRoot)), + )).flat(); + const manifestSkillExclusions = manifestExclusions(manifest, "skills"); + const manifestPromptExclusions = manifestExclusions(manifest, "prompts"); + plugin.skills = applyPiResourceFilter( + collectedSkills.filter((item) => !manifestSkillExclusions.some((pattern) => matchesPiPattern(pattern, piItemCandidates(item, packageRoot)))), + packageRoot, + entryConfig.filters.skills, + ).sort(sortByName); + plugin.commands = applyPiResourceFilter( + collectedPrompts.filter((item) => !manifestPromptExclusions.some((pattern) => matchesPiPattern(pattern, piItemCandidates(item, packageRoot)))), + packageRoot, + entryConfig.filters.prompts, + ).sort(sortByName); + plugin.rules = []; + plugin.subagents = []; + plugin.hooks = []; + plugin.mcpServers = []; + return plugin; +} + +async function collectPiExtensionPlugins(piHome) { + const extensionsRoot = path.join(piHome, "extensions"); + const plugins = []; + for (const extensionDir of await listDirectories(extensionsRoot)) { + const name = path.basename(extensionDir); + const record = { + id: `pi/user/extensions/${name}`, + name, + source: extensionDir, + sourceKind: "extension-dir", + installPath: extensionDir, + }; + const plugin = await collectPiPackagePlugin(record, "user"); + if (plugin) { + plugin.installMatch = "pi-extensions-dir"; + plugin.installType = "extension-dir"; + plugins.push(plugin); + } + } + return plugins; +} + +async function collectPiPackagePlugins(settings, settingsPath, scopeRoot, scope) { + const entries = Array.isArray(settings?.packages) ? settings.packages : []; + const plugins = []; + for (const entry of entries) { + const entryConfig = normalizePiPackageEntry(entry); + if (!entryConfig) continue; + const record = resolvePiPackageRecord(entryConfig.source, { + scopeRoot, + settingsDir: path.dirname(settingsPath), + scope, + }); + const plugin = await collectPiPackagePlugin(record, scope, entryConfig); + if (plugin) plugins.push(plugin); + } + return plugins; +} + +async function agentsSkillsDir(base) { + return path.join(base, ".agents", "skills"); +} + +async function collectPiUserPrimitives(piHome, userHome) { + const globalRules = await collectRuleSources([ + { + type: "file", + filePath: path.join(piHome, "AGENTS.md"), + scope: "user", + sourceLabel: "User", + rootForEvidence: piHome, + name: "AGENTS.md", + sourceKind: "pi-global-context", + precedence: "before-project-rules", + useHeading: true, + }, + ]); + return { + skills: [ + ...(await collectSkillFiles(path.join(piHome, "skills"), "user", "User", piHome)), + // Pi discovers the Agent Skills standard directory from the real user + // home even when PI_CODING_AGENT_DIR relocates the agent dir; model the + // two roots independently instead of deriving one from the other. + ...(await collectSkillFiles(await agentsSkillsDir(userHome), "user", "User", userHome)), + ].sort(sortByName), + subagents: [], + rules: globalRules, + commands: await collectMarkdownItems(path.join(piHome, "prompts"), "command", "user", "User", piHome), + hooks: [], + mcps: [], + }; +} + +async function collectPiWorkspacePrimitives(workspace) { + const sourceLabel = await workspaceSourceLabel(workspace); + const project = await collectWorkspaceRootPrimitives(path.join(workspace, ".pi"), sourceLabel, workspace); + // Pi prompt templates register as slash commands from .pi/prompts. + const promptCommands = await collectMarkdownItems( + path.join(workspace, ".pi", "prompts"), "command", "project", sourceLabel, workspace, + ); + for (const command of promptCommands) { + if (!project.commands.some((item) => item.filePath === command.filePath)) { + project.commands.push(command); + } + } + project.commands.sort(sortByName); + const agentsStandardSkills = await collectSkillFiles( + await agentsSkillsDir(workspace), "project", sourceLabel, workspace, + ); + project.skills = [...project.skills, ...agentsStandardSkills].sort(sortByName); + return { + ...project, + rules: [ + ...project.rules, + ...(await collectRuleSources([ + agentsMarkdownRuleSource(workspace, sourceLabel), + designMarkdownRuleSource(workspace, sourceLabel), + ])), + ], + }; +} + +function emptyPrimitives() { + return { skills: [], subagents: [], rules: [], commands: [], hooks: [], mcps: [] }; +} + +export async function collectPiCustomizeInventory(options = {}) { + const piHome = path.resolve(expandHome(options.piHome ?? options["pi-home"] ?? defaultPiHome())); + const userHome = path.resolve(expandHome(options.piUserHome ?? options["pi-user-home"] ?? os.homedir())); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const includeUserHome = options.includeUserHome !== false; + const userSettingsPath = path.join(piHome, "settings.json"); + const projectSettingsPath = path.join(workspace, ".pi", "settings.json"); + const userSettings = includeUserHome ? (await readJson(userSettingsPath)) ?? {} : {}; + const projectSettings = (await readJson(projectSettingsPath)) ?? {}; + const [userPackagePlugins, extensionPlugins, projectPackagePlugins, user, project] = await Promise.all([ + includeUserHome ? collectPiPackagePlugins(userSettings, userSettingsPath, piHome, "user") : [], + includeUserHome ? collectPiExtensionPlugins(piHome) : [], + collectPiPackagePlugins(projectSettings, projectSettingsPath, path.join(workspace, ".pi"), "project"), + includeUserHome ? collectPiUserPrimitives(piHome, userHome) : emptyPrimitives(), + collectPiWorkspacePrimitives(workspace), + ]); + // Pi resolves the same source declared in both scopes to the project entry. + const bySource = new Map(); + for (const plugin of [...projectPackagePlugins, ...userPackagePlugins, ...extensionPlugins]) { + const key = plugin.piPackageSource ?? plugin.id; + if (!bySource.has(key)) bySource.set(key, plugin); + } + const plugins = [...bySource.values()].sort(sortByName); + return { + generatedAt: new Date().toISOString(), + provider: "pi", + piHome, + workspace, + tabs: MANAGE_TABS, + plugins, + manage: buildManageCollections(plugins, user, project), + diagnostics: { + installedPluginState: plugins.length > 0 ? "pi-settings-packages" : "missing", + installedPluginRecordCount: plugins.length, + installedPluginRecordFiles: [ + ...(includeUserHome && Array.isArray(userSettings.packages) && userSettings.packages.length > 0 + ? [userSettingsPath] + : []), + ...(Array.isArray(projectSettings.packages) && projectSettings.packages.length > 0 + ? [projectSettingsPath] + : []), + ], + remotePluginInstallMarkersRequired: false, + }, + unsupported: [ + "pi manifest glob source expansion outside declared resource directories", + "pi config per-resource enable state beyond settings package entries", + "native MCP inventory (Pi loads MCP through extensions)", + "extension runtime registration state", + "theme inventory", + ], + }; +} diff --git a/scripts/agent-lint/cli.mjs b/scripts/agent-lint/cli.mjs index a66279b..670c083 100644 --- a/scripts/agent-lint/cli.mjs +++ b/scripts/agent-lint/cli.mjs @@ -167,7 +167,7 @@ function usage() { return [ "Usage: better-harness agent-lint [--workspace ] [--profile agents-md-review|agent-assets-review] [--json|--format markdown]", " better-harness agent-lint --workspace-root --scan-children --profile agents-md-review", - " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", + " better-harness agent-lint --profile agent-assets-review --provider [--skill ]", "", "Parse agent instruction entrypoints and bounded local Markdown references into review evidence.", "", diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 71c892e..5ff9305 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, and Copilot session evidence.", + summary: "Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, and Pi session evidence.", subcommands: [ { name: "sources", diff --git a/scripts/coding-agent-practices/asset-baseline.mjs b/scripts/coding-agent-practices/asset-baseline.mjs index 9c1c433..9c87931 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"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]); 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.`); + throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot, pi.`); } 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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-baseline [qoder|codex|claude|cursor|qwen|copilot|pi] [options] Collect one compact, read-only AI evidence envelope from a shared asset snapshot. diff --git a/scripts/coding-agent-practices/asset-integrity.mjs b/scripts/coding-agent-practices/asset-integrity.mjs index a7585d4..a770185 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] [options] +const USAGE = `Usage: better-harness coding-agent-practices asset-integrity [qoder|codex|claude|cursor|qwen|copilot|pi] [options] Run a read-only metadata integrity review for Memory titles, enabled Plugins, and Hooks. Memory bodies are never read. @@ -368,8 +368,8 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const provider = options.provider ?? options.platform ?? command ?? "qoder"; - if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot"].includes(provider)) { - throw new Error(`Unsupported provider: ${provider}. Supported providers: qoder, codex, claude, cursor, qwen, copilot.`); + 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.`); } 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 31ff229..b8df856 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"].includes(scope.platform)) { + if (!["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(scope.platform)) { return []; } return [ @@ -449,6 +449,7 @@ function providerScope(options = {}, platform = options.platform ?? "qoder") { claudeStatePath: options.claudeStatePath ?? options["claude-state"] ?? options["claude-state-path"], qwenHome: options.qwenHome ?? options["qwen-home"], copilotHome: options.copilotHome ?? options["copilot-home"], + piHome: options.piHome ?? options["pi-home"], }; } @@ -537,7 +538,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; + const userBase = inventory.cursorHome ?? inventory.qoderHome ?? inventory.codexHome ?? inventory.claudeHome ?? inventory.qwenHome ?? inventory.copilotHome ?? inventory.piHome; const surfaceTypes = [ ["skills", "skills", "Skills"], ["subagents", "agents", "Agents"], @@ -641,6 +642,7 @@ export async function collectProviderInventory(options = {}) { claudeStatePath: scope.claudeStatePath, qwenHome: scope.qwenHome, copilotHome: scope.copilotHome, + piHome: scope.piHome, includeUserHome: scope.includeUserHome, includeGlobalHooks: scope.includeGlobalHooks, }); @@ -892,12 +894,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] [options] +const USAGE = `Usage: better-harness coding-agent-practices inventory [qoder|codex|claude|cursor|qwen|copilot|pi] [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 @@ -919,9 +921,9 @@ async function runCli(argv) { } const { command, options } = parseArgs(argv); const platform = options.platform ?? command ?? "qoder"; - if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot"].includes(platform)) { + if (!["cursor", "qoder", "codex", "claude", "qwen", "copilot", "pi"].includes(platform)) { throw new Error( - `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot.\n\n${USAGE}`, + `Unsupported platform: ${platform}. Supported platforms: cursor, qoder, codex, claude, qwen, copilot, pi.\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 6eb0eb4..37fe8a7 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"]); +const ASSET_PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]); 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 8b24f51..b36eb86 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, or copilot (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, or pi (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 @@ -33,7 +33,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", "claude-state", "help", "h", + "cursor-home", "qwen-home", "copilot-home", "pi-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 f395a25..37aabb7 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"]); +const PROVIDERS = new Set(["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]); 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 27625e2..7a6faab 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|qoder|codex|claude|cursor|qwen|copilot|session-analysis|session sources|session evidence|会话分析|会话证据)/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_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 6156f93..39aeae6 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, or copilot (default: qoder) + --platform qoder, codex, claude, cursor, qwen, copilot, or pi (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"]; +const REPORT_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]; 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", + "qoder-home", "codex-home", "claude-home", "cursor-home", "qwen-home", "copilot-home", "pi-home", ]); const positional = Array.isArray(options._) ? options._ : []; const unknown = Object.keys(options).filter((key) => key !== "_" && !allowed.has(key)); @@ -168,6 +168,7 @@ export async function analyzeHarnessEvidence(options = {}) { claudeHome: options["claude-home"], cursorHome: options["cursor-home"], qwenHome: options["qwen-home"], + piHome: options["pi-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 f7a2b63..6c471c5 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"].includes(source?.manifest?.scope?.platform) + const platform = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].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 20c935a..735e2b8 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, or Copilot sessions. It retains privacy-safe episode, change, validation, +Qoder, Codex, Claude, Cursor, Qwen, Copilot, or Pi 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"].includes(provider); + const assetReviewSupported = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(provider); const common = { workspace: options.workspace, provider, @@ -823,6 +823,7 @@ export async function collectAgentLintPracticeEvidence(options = {}) { cursorHome: options.cursorHome ?? options["cursor-home"], qwenHome: options.qwenHome ?? options["qwen-home"], copilotHome: options.copilotHome ?? options["copilot-home"], + piHome: options.piHome ?? options["pi-home"], }; const [instructionReview, assetReview, practiceInventory] = await Promise.all([ runAgentLint({ ...common, profile: "agents-md-review" }), @@ -920,6 +921,16 @@ export function collectTaskLoopPracticeInventory(options = {}, platform = option qwenHome: options.qwenHome ?? options["qwen-home"], }); } + if (platform === "pi") { + return collectProviderInventory({ + platform, + workspace: options.workspace, + includeUserHome: includeGlobalCapabilities, + includeGlobalHooks: true, + includeMemories: false, + piHome: options.piHome ?? options["pi-home"], + }); + } return Promise.resolve(null); } diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 2c4d532..21db852 100644 --- a/scripts/npm-package/verify-pack.mjs +++ b/scripts/npm-package/verify-pack.mjs @@ -143,6 +143,7 @@ const required = [ "package/.github/plugin/marketplace.json", "package/.qoder-plugin/plugin.json", "package/qwen-extension.json", + "package/prompts/better-harness.md", "package/case-studies/factory/model/factory-readiness.md", "package/docs/glossary.md", "package/scripts/better-harness.mjs", @@ -337,6 +338,7 @@ const forbiddenBundlePrefixes = [ ".cursor-plugin/", ".github/plugin/", "qwen-extension.json", + "prompts/", "test/", "dev/", ".idea/", diff --git a/scripts/session-analysis.mjs b/scripts/session-analysis.mjs index bd08a58..2ea2bc6 100644 --- a/scripts/session-analysis.mjs +++ b/scripts/session-analysis.mjs @@ -238,7 +238,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot.`); + if (platform === "pi") { + const module = await import("./session-analysis/platforms/pi.mjs"); + return { + Analyzer: module.PiSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi.`); } export async function createAnalyzer(platform = "qoder") { @@ -288,7 +295,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 5732f05..c6ae0f0 100644 --- a/scripts/session-analysis/analyzer.mjs +++ b/scripts/session-analysis/analyzer.mjs @@ -19,7 +19,7 @@ import { createCodexCliJsonModelClient } from "./codex-json-model.mjs"; export const SESSION_ANALYSIS_HELP = `Usage: better-harness session-analysis [command] [options] -Inspect local Qoder, Codex, Claude, Cursor, Qwen, or Copilot session evidence. The +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 reading HOME or workspace. @@ -35,7 +35,7 @@ Commands: events Show normalized events selected with --session-id Options: - --platform + --platform Session host (default: qoder) --workspace Workspace scope (default: current directory) --qoder-home Qoder data root (default: ~/.qoder) @@ -273,7 +273,14 @@ async function loadPlatform(platform = "qoder") { main: module.main, }; } - throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot.`); + if (platform === "pi") { + const module = await import("./platforms/pi.mjs"); + return { + Analyzer: module.PiSessionAnalyzer, + main: module.main, + }; + } + throw new Error(`Unsupported platform: ${platform}. Supported platforms: qoder, codex, claude, cursor, qwen, copilot, pi.`); } export async function createAnalyzer(platform = "qoder") { @@ -287,7 +294,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/lifecycle-demand-signals.mjs b/scripts/session-analysis/lifecycle-demand-signals.mjs index 63500bf..a2e99fd 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"].includes(host) ? host : "unknown"; + return ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"].includes(host) ? host : "unknown"; } function safeEvidenceToken(value, fallback) { diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs new file mode 100644 index 0000000..524cdbf --- /dev/null +++ b/scripts/session-analysis/platforms/pi.mjs @@ -0,0 +1,441 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { SessionAnalyzer } from "../../session-analysis.mjs"; +import { parseArgs, parseBooleanFlag } from "../cli.mjs"; +import { forEachJsonLine, pathExists, 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"; + +function isWorkspaceMatch(candidate, workspace) { + if (!candidate) return false; + const resolved = normalizeWorkspace(candidate); + return resolved === workspace || resolved.startsWith(`${workspace}${path.sep}`); +} + +export function workspaceToPiSessionDirVariants(workspace) { + const expanded = expandHome(workspace ?? process.cwd()); + const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); + // Match pi's session directory naming: strip one leading separator, then + // replace every "/", "\", and ":" with "-", wrapped as ----. + const body = normalized.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + return { + exact: `--${body}--`, + // Sessions started in a subdirectory of the workspace live in their own + // cwd-keyed directory; its name starts with the workspace slug body. + prefix: `--${body}-`, + }; +} + +function sessionIdFromFileName(filePath) { + // Pi session files are named _.jsonl. + const base = path.basename(filePath, ".jsonl"); + const separator = base.indexOf("_"); + return separator >= 0 ? base.slice(separator + 1) : base; +} + +function inferTimestamp(raw) { + return normalizeTimestamp(raw?.timestamp ?? raw?.message?.timestamp ?? null); +} + +function contentBlocks(raw) { + const content = raw?.message?.content; + if (Array.isArray(content)) return content; + if (typeof content === "string") return [{ type: "text", text: content }]; + return []; +} + +function textFromBlocks(blocks) { + return blocks + .filter((block) => typeof block === "string" || (block && typeof block === "object" && block.type === "text")) + .map((block) => (typeof block === "string" ? block : block.text)) + .filter(Boolean) + .join("\n") + .trim(); +} + +function inferUsage(raw) { + const usage = raw?.message?.usage; + if (!usage || typeof usage !== "object") return null; + // Pi records usage as finite numbers. Keep partial usage explicit: carry + // only the fields the transcript actually observed and never coerce a + // missing or malformed sibling field to zero. + const observed = {}; + for (const [key, value] of [ + ["inputTokens", usage.input], + ["outputTokens", usage.output], + ["cacheReadInputTokens", usage.cacheRead], + ["cacheCreationInputTokens", usage.cacheWrite], + ]) { + if (typeof value === "number" && Number.isFinite(value)) observed[key] = value; + } + return Object.keys(observed).length > 0 ? observed : null; +} + +function inferFilePath(toolName, input = {}) { + if (!/(?:read|edit|write|file|notebook)/i.test(String(toolName ?? ""))) return null; + return input.file_path ?? input.filePath ?? input.path ?? null; +} + +function inferCommandText(toolName, input = {}) { + if (!/(?:bash|shell|exec|terminal|run)/i.test(String(toolName ?? ""))) return null; + return input.command ?? input.cmd ?? null; +} + +function evidenceRef(sourceRef, type, itemIndex = null) { + return { + kind: sourceRef.kind, + path: sourceRef.path, + line: sourceRef.line ?? null, + seq: itemIndex, + type, + }; +} + +function transcriptEvents(raw, sourceRef, options) { + const rawType = raw?.type ?? "record"; + const role = raw?.message?.role ?? null; + const timestamp = inferTimestamp(raw); + const events = []; + const base = { + sessionId: sourceRef.sessionId, + timestamp, + sourceKind: sourceRef.kind, + planningScope: "workspace", + cwd: raw?.cwd ?? null, + isSubagent: null, + }; + + if (rawType === "message" && role === "user") { + const text = textFromBlocks(contentBlocks(raw)); + events.push({ + ...base, + type: "user", + category: "user", + evidenceRef: evidenceRef(sourceRef, "user"), + summary: text ? `user message (${text.length} chars)` : "user", + contentLength: text.length, + userPrompt: text.length > 0, + ...(options.includeUserText && text ? { userText: text } : {}), + ...(options.includeContent && text ? { content: text } : {}), + }); + } else if (rawType === "message" && role === "assistant") { + const blocks = contentBlocks(raw); + const model = raw?.message?.model ?? null; + const usage = inferUsage(raw); + const visibleText = textFromBlocks(blocks); + const event = { + ...base, + type: "assistant", + category: "assistant", + evidenceRef: evidenceRef(sourceRef, "assistant"), + summary: visibleText ? `assistant message (${visibleText.length} chars)` : "assistant", + contentLength: visibleText.length, + }; + if (visibleText) event.userVisibleAssistantMessage = true; + if (options.includeContent && visibleText) event.content = visibleText; + if (model && !usage) event.model = model; + events.push(event); + if (usage) { + events.push({ + ...base, + type: "model.response.completed", + category: "model", + model, + modelUsage: usage, + usageFieldsObserved: true, + responseId: raw?.id ?? null, + evidenceRef: evidenceRef(sourceRef, "model.response.completed"), + summary: "Pi model response completed", + }); + } + blocks.forEach((block, index) => { + if (!block || typeof block !== "object" || block.type !== "toolCall") return; + const input = block.arguments && typeof block.arguments === "object" ? block.arguments : {}; + const toolEvent = { + ...base, + type: "tool.call", + category: "tool", + lifecyclePhase: "request", + toolName: block.name ?? "unknown-tool", + toolInvocationId: block.id ?? null, + evidenceRef: evidenceRef(sourceRef, "tool.call", index), + summary: `${block.name ?? "unknown-tool"} request`, + }; + const commandText = inferCommandText(block.name, input); + const filePath = inferFilePath(block.name, input); + if (options.includeCommandText && commandText) toolEvent.commandText = commandText; + if (filePath) toolEvent.filePath = filePath; + events.push(toolEvent); + }); + } else if (rawType === "message" && role === "toolResult") { + const message = raw.message ?? {}; + const success = message.isError !== true; + const output = textFromBlocks(contentBlocks(raw)); + const event = { + ...base, + type: "tool.result", + category: "tool", + lifecyclePhase: "result", + toolInvocationId: message.toolCallId ?? null, + success, + hasError: !success, + evidenceRef: evidenceRef(sourceRef, "tool.result"), + summary: !success ? "tool result failed" : "tool result", + }; + if (message.toolName) event.toolName = message.toolName; + const resultFacts = parseResultFacts(String(output).slice(-8_192)); + if (resultFacts) event.resultFacts = resultFacts; + events.push(event); + } else if (rawType === "session") { + events.push({ + ...base, + type: "metadata.session", + category: "metadata", + evidenceRef: evidenceRef(sourceRef, "metadata.session"), + summary: raw?.version ? `session header (v${raw.version})` : "session header", + }); + } else { + // model_change, thinking_level_change, compaction, session_info, + // custom, custom_message, and other lifecycle records stay metadata. + events.push({ + ...base, + type: `metadata.${rawType}`, + category: "metadata", + evidenceRef: evidenceRef(sourceRef, `metadata.${rawType}`), + summary: rawType, + }); + } + + return events; +} + +async function probeTranscript(filePath, workspace) { + const summary = { + sessionId: sessionIdFromFileName(filePath), + firstSeen: null, + lastSeen: null, + workspaceMatch: false, + }; + await forEachJsonLine(filePath, (raw) => { + if (raw?.type === "session") { + if (raw.id) summary.sessionId = raw.id; + if (isWorkspaceMatch(raw.cwd, workspace)) summary.workspaceMatch = true; + } + mergeTimeRange(summary, inferTimestamp(raw)); + }); + return summary; +} + +function addRef(sessions, sessionId, workspace, ref) { + if (!sessionId) return; + const session = sessions.get(sessionId) ?? { + sessionId, + workspace, + 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() }; +} + +async function listSessionDirectories(sessionsRoot, variants) { + let entries; + try { + entries = await readdir(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => name === variants.exact || name.startsWith(variants.prefix)) + .map((name) => path.join(sessionsRoot, name)); +} + +async function readSettingsSessionDir(settingsPath) { + try { + const raw = await readFile(settingsPath, "utf8"); + const settings = JSON.parse(raw); + return typeof settings?.sessionDir === "string" && settings.sessionDir.trim() !== "" ? settings.sessionDir : null; + } catch { + return null; + } +} + +// Pi treats --session-dir, PI_CODING_AGENT_SESSION_DIR, and the settings +// `sessionDir` key as the exact directory that contains session JSONL files. +// Only the built-in default is a cwd-keyed tree of ---- children +// under /sessions. Resolution order matches Pi: CLI option, then +// environment, then project settings over global settings, then the default. +async function resolveSessionDirContract(options, home, workspace) { + const cliDir = options.sessionsDir ?? options["sessions-dir"] ?? options["session-dir"] ?? options.sessionDir; + if (cliDir) { + return { mode: "custom", dir: path.resolve(expandHome(cliDir)) }; + } + if (process.env.PI_CODING_AGENT_SESSION_DIR) { + return { mode: "custom", dir: path.resolve(expandHome(process.env.PI_CODING_AGENT_SESSION_DIR)) }; + } + const settingsDir = (await readSettingsSessionDir(path.join(workspace, ".pi", "settings.json"))) + ?? (await readSettingsSessionDir(path.join(home, "settings.json"))); + if (settingsDir) { + return { mode: "custom", dir: path.resolve(expandHome(settingsDir)) }; + } + return { mode: "default", dir: path.join(home, "sessions") }; +} + +export class PiSessionAnalyzer extends SessionAnalyzer { + currentSessionId() { + return process.env.PI_SESSION_ID ?? null; + } + + async resolveScope(options = {}) { + const since = normalizeCliDate(options.since, false); + const until = normalizeCliDate(options.until, true); + const workspace = normalizeWorkspace(options.workspace); + // Pi resolves its agent dir from PI_CODING_AGENT_DIR (default ~/.pi/agent). + const home = path.resolve(expandHome( + options.home ?? options.piHome ?? options["pi-home"] ?? process.env.PI_CODING_AGENT_DIR ?? "~/.pi/agent", + )); + const sessionDirContract = await resolveSessionDirContract(options, home, workspace); + return { + platform: "pi", + workspace, + home, + sessionsDir: sessionDirContract.dir, + sessionDirMode: sessionDirContract.mode, + _workspaceDirVariants: workspaceToPiSessionDirVariants(workspace), + 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 discoverSourceRoots(scope) { + const custom = scope.sessionDirMode === "custom"; + const root = { + id: "pi-sessions", + kind: "pi-session-jsonl", + role: "session-transcript", + path: custom ? scope.sessionsDir : path.join(scope.sessionsDir, scope._workspaceDirVariants.exact), + paths: [scope.sessionsDir], + optional: false, + enabled: true, + workspaceScoped: true, + coverage: "primary", + sessionDirMode: scope.sessionDirMode, + }; + // A parent directory alone is not proof of a workspace evidence root: + // in the default tree, existence requires at least one workspace-keyed + // session directory; a custom flat directory must itself exist. + const exists = custom + ? await pathExists(scope.sessionsDir) + : (await listSessionDirectories(scope.sessionsDir, scope._workspaceDirVariants)).length > 0; + return [{ ...root, exists }]; + } + + async discoverSessions(scope, roots) { + const sessions = new Map(); + const transcriptRoot = roots.find((root) => root.kind === "pi-session-jsonl"); + if (!transcriptRoot) return []; + const custom = scope.sessionDirMode === "custom"; + const seenDirs = new Set(); + for (const sessionsRoot of transcriptRoot.paths ?? []) { + if (!await pathExists(sessionsRoot)) continue; + // Custom session directories contain JSONL files directly; the default + // tree nests them under workspace-keyed ---- directories. + const candidateDirs = custom + ? [sessionsRoot] + : await listSessionDirectories(sessionsRoot, scope._workspaceDirVariants); + for (const dirPath of candidateDirs) { + let realDir; + try { realDir = realpathSync.native(dirPath); } catch { realDir = path.resolve(dirPath); } + if (seenDirs.has(realDir)) continue; + seenDirs.add(realDir); + const files = await walkFiles(dirPath, { maxDepth: 1, limit: 20_000, match: (file) => file.endsWith(".jsonl") }); + for (const filePath of files) { + // Both modes qualify each transcript by the session-header cwd, so a + // shared custom directory never leaks foreign-workspace sessions. + const probe = await probeTranscript(filePath, scope.workspace); + if (!probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; + addRef(sessions, probe.sessionId, scope.workspace, { + kind: transcriptRoot.kind, + role: transcriptRoot.role, + path: filePath, + firstSeen: probe.firstSeen, + lastSeen: probe.lastSeen, + }); + } + } + } + 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 transcriptEvents(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) => { + if (raw?.type === "session" && raw?.cwd && !isWorkspaceMatch(raw.cwd, scope.workspace)) return; + for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { + if (withinTimeRange(event.timestamp, scope)) events.push(event); + } + }); + } + return 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 analyze(options = {}) { + return runProviderAnalysis(this, options, { platform: "pi", adapterVersion: "pi-v1" }); + } +} + +export async function main(argv = process.argv.slice(2)) { + const { command = "sessions", options } = parseArgs(argv); + const analyzer = new PiSessionAnalyzer(); + const result = await runProviderCommand(analyzer, command, options); + await emitProviderResult({ provider: "Pi", 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(`pi 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 f421a3b..d0c32c3 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 1dcb279..459c378 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/test/agent-asset-baseline.test.mjs b/test/agent-asset-baseline.test.mjs index 7b785d4..4208704 100644 --- a/test/agent-asset-baseline.test.mjs +++ b/test/agent-asset-baseline.test.mjs @@ -288,3 +288,34 @@ test("Qwen asset baseline completes from a native project fixture", async () => await rm(root, { recursive: true, force: true }); } }); + +test("Pi asset baseline completes from a native project fixture", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-asset-baseline-pi-")); + const workspace = path.join(root, "project"); + const piHome = path.join(root, ".pi-home", ".pi", "agent"); + try { + await mkdir(piHome, { recursive: true }); + await mkdir(path.join(workspace, ".pi", "skills", "review"), { recursive: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "# Pi project\n\nRun npm test.\n"); + await writeFile( + path.join(workspace, ".pi", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review a bounded Pi project change.\n---\n", + ); + + const result = await collectAssetBaseline({ + provider: "pi", + workspace, + piHome, + includeUserHome: false, + }); + + assert.equal(result.status, "complete"); + assert.equal(result.scope.provider, "pi"); + 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 5e2d874..c023aee 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -54,7 +54,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 the Qoder, Claude Code,\s+Codex, Cursor, Qwen, and GitHub Copilot plugin metadata roots/u); + assert.match(adapterReadme, /npm package includes the Qoder, Claude Code,\s+Codex, Cursor, Qwen, Copilot, and Pi plugin metadata roots/u); assert.match(adapterReadme, /generated\s+Qoder runtime bundle 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); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 1009ab7..5c2be49 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -1801,3 +1801,210 @@ test("agent-customize CLI honours --copilot-home instead of the real user home", await rm(fixture.root, { recursive: true, force: true }); } }); + +async function makePiFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-pi-")); + const piHome = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace"); + + const npmPackageRoot = path.join(piHome, "npm", "node_modules", "@scope", "delivery-pack"); + await writeJson(path.join(npmPackageRoot, "package.json"), { + name: "@scope/delivery-pack", + version: "1.2.0", + description: "Delivery workflows for pi.", + pi: { skills: ["./skills"], prompts: ["./prompts"] }, + }); + await writeText( + path.join(npmPackageRoot, "skills", "delivery-review", "SKILL.md"), + "---\nname: delivery-review\ndescription: Review delivery gates.\n---\n", + ); + await writeText(path.join(npmPackageRoot, "prompts", "ship-check.md"), "# Ship Check\n"); + + const gitPackageRoot = path.join(piHome, "git", "github.com", "acme", "pi-toolkit"); + await writeJson(path.join(gitPackageRoot, "package.json"), { + name: "pi-toolkit", + version: "0.9.0", + }); + await writeText( + path.join(gitPackageRoot, "skills", "toolkit-audit", "SKILL.md"), + "---\nname: toolkit-audit\ndescription: Audit toolkit output.\n---\n", + ); + + await writeJson(path.join(piHome, "settings.json"), { + packages: [ + "npm:@scope/delivery-pack@1.2.0", + { source: "git:github.com/acme/pi-toolkit@v0.9.0" }, + ], + }); + await writeText( + path.join(piHome, "skills", "local-review", "SKILL.md"), + "---\nname: local-review\ndescription: Review code locally.\n---\n", + ); + await writeText(path.join(piHome, "prompts", "user-check.md"), "# User Check\n"); + await writeText(path.join(piHome, "AGENTS.md"), "# Global Pi Guidance\n"); + await writeText( + path.join(piHome, "extensions", "guardrail-extension", "package.json"), + `${JSON.stringify({ name: "guardrail-extension", version: "0.1.0" }, null, 2)}\n`, + ); + + await writeText( + path.join(workspace, ".pi", "skills", "pi-workflow", "SKILL.md"), + "---\nname: pi-workflow\ndescription: Pi workflow.\n---\n", + ); + await writeText(path.join(workspace, ".pi", "prompts", "release-notes.md"), "# Release Notes\n"); + await writeText( + path.join(workspace, ".agents", "skills", "shared-standard", "SKILL.md"), + "---\nname: shared-standard\ndescription: Shared Agent Skills standard workflow.\n---\n", + ); + await writeText(path.join(workspace, "AGENTS.md"), "# Pi Project Instructions\n"); + + return { root, piHome, workspace }; +} + +test("collectAgentCustomizeInventory returns Pi packages and extensions as plugins", async () => { + const fixture = await makePiFixture(); + + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "pi", + piHome: fixture.piHome, + workspace: fixture.workspace, + }); + + assert.equal(inventory.provider, "pi"); + assert.equal(inventory.piHome, fixture.piHome); + assert.equal(inventory.plugins.length, 3); + + const delivery = inventory.plugins.find((plugin) => plugin.name === "@scope/delivery-pack"); + assert.ok(delivery); + assert.equal(delivery.installMatch, "pi-settings-npm"); + assert.equal(delivery.version, "1.2.0"); + assert.deepEqual(delivery.skills.map((skill) => skill.name), ["delivery-review"]); + assert.deepEqual(delivery.commands.map((command) => command.name), ["ship-check"]); + + const toolkit = inventory.plugins.find((plugin) => plugin.name === "pi-toolkit"); + assert.ok(toolkit); + assert.equal(toolkit.installMatch, "pi-settings-git"); + assert.deepEqual(toolkit.skills.map((skill) => skill.name), ["toolkit-audit"]); + + const extension = inventory.plugins.find((plugin) => plugin.name === "guardrail-extension"); + assert.ok(extension); + assert.equal(extension.installMatch, "pi-extensions-dir"); + + assert.equal(inventory.diagnostics.installedPluginState, "pi-settings-packages"); + assert.deepEqual( + inventory.diagnostics.installedPluginRecordFiles, + [path.join(fixture.piHome, "settings.json")], + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("Pi provider collects user and project skills, prompt commands, and context rules", async () => { + const fixture = await makePiFixture(); + + try { + const inventory = await collectAgentCustomizeInventory({ + provider: "pi", + piHome: fixture.piHome, + workspace: fixture.workspace, + }); + + assert.ok( + filterManageItems(inventory, { tab: "skills", scopeKind: "user" }) + .some((item) => item.name === "local-review" && item.scope === "user"), + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "skills", scopeKind: "project" }).map((item) => item.name).sort(), + ["pi-workflow", "shared-standard"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "commands", scopeKind: "user" }).map((item) => item.name).sort(), + ["ship-check", "user-check"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "commands", scopeKind: "project" }).map((item) => item.name), + ["release-notes"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "rules", scopeKind: "user" }).map( + (item) => `${item.name}:${item.sourceKind ?? "native"}`, + ), + ["AGENTS.md:pi-global-context"], + ); + assert.deepEqual( + filterManageItems(inventory, { tab: "rules", scopeKind: "project" }).map( + (item) => `${item.name}:${item.sourceKind ?? "native"}`, + ).sort(), + ["AGENTS.md:agents-md-compat"].sort(), + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("Pi fails closed on autoload:false and honors package resource filters", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-pi-filters-")); + const piHome = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace"); + try { + const disabledRoot = path.join(piHome, "npm", "node_modules", "disabled-pack"); + await writeJson(path.join(disabledRoot, "package.json"), { name: "disabled-pack", version: "1.0.0" }); + await writeText(path.join(disabledRoot, "skills", "hidden", "SKILL.md"), "---\nname: hidden\ndescription: Hidden skill.\n---\n"); + await writeText(path.join(disabledRoot, "prompts", "hidden.md"), "# Hidden\n"); + + const filteredRoot = path.join(piHome, "npm", "node_modules", "filtered-pack"); + await writeJson(path.join(filteredRoot, "package.json"), { name: "filtered-pack", version: "1.0.0" }); + await writeText(path.join(filteredRoot, "skills", "keep", "SKILL.md"), "---\nname: keep\ndescription: Keep this skill.\n---\n"); + await writeText(path.join(filteredRoot, "skills", "drop", "SKILL.md"), "---\nname: drop\ndescription: Drop this skill.\n---\n"); + await writeText(path.join(filteredRoot, "prompts", "run.md"), "# Run\n"); + + await writeJson(path.join(piHome, "settings.json"), { + packages: [ + { source: "npm:disabled-pack", autoload: false, skills: [], prompts: [] }, + { source: "npm:filtered-pack", skills: ["skills/keep/SKILL.md"], prompts: [] }, + ], + }); + + const inventory = await collectAgentCustomizeInventory({ provider: "pi", piHome, workspace }); + const disabled = inventory.plugins.find((plugin) => plugin.name === "disabled-pack"); + assert.ok(disabled); + assert.equal(disabled.enabled, false); + assert.deepEqual(disabled.skills, []); + assert.deepEqual(disabled.commands, []); + + const filtered = inventory.plugins.find((plugin) => plugin.name === "filtered-pack"); + assert.ok(filtered); + assert.deepEqual(filtered.skills.map((skill) => skill.name), ["keep"]); + assert.deepEqual(filtered.commands, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Pi discovers ~/.agents/skills from the real user home under a relocated agent dir", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-pi-home-")); + const userHome = path.join(root, "home"); + const piHome = path.join(root, "custom-agent-dir", "agent"); + const workspace = path.join(root, "workspace"); + try { + await mkdir(piHome, { recursive: true }); + await writeText( + path.join(userHome, ".agents", "skills", "global-standard", "SKILL.md"), + "---\nname: global-standard\ndescription: Global Agent Skills standard workflow.\n---\n", + ); + await writeText( + path.join(piHome, "skills", "agent-dir-skill", "SKILL.md"), + "---\nname: agent-dir-skill\ndescription: Skill under the relocated agent dir.\n---\n", + ); + await mkdir(workspace, { recursive: true }); + + const inventory = await collectAgentCustomizeInventory({ provider: "pi", piHome, piUserHome: userHome, workspace }); + const userSkills = filterManageItems(inventory, { tab: "skills", scopeKind: "user" }).map((item) => item.name); + assert.ok(userSkills.includes("global-standard"), `expected ~/.agents/skills discovery, got ${userSkills.join(", ")}`); + assert.ok(userSkills.includes("agent-dir-skill")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/better-harness-evidence-bundle.test.mjs b/test/better-harness-evidence-bundle.test.mjs index d49ae14..5f877fb 100644 --- a/test/better-harness-evidence-bundle.test.mjs +++ b/test/better-harness-evidence-bundle.test.mjs @@ -193,3 +193,26 @@ test("Qwen agentCustomize lane routes the provider and isolated config paths", a assert.equal(received["qwen-home"], "/tmp/fixture-qwen-home"); assert.equal(received["include-user-home"], true); }); + +test("Pi agentCustomize lane routes the provider and isolated config paths", async () => { + const context = freezeEvidenceBundleContext({ + workspace: ".", + platform: "pi", + depth: "quick", + "include-user-home": true, + }, NOW); + let received; + const lane = await collectAgentCustomize(context, { + "pi-home": "/tmp/fixture-pi-home", + }, { + collectAssetBaseline: async (options) => { + received = options; + return { kind: "agent-asset-baseline", status: "complete" }; + }, + }); + + assert.equal(lane.status, "available"); + assert.equal(received.provider, "pi"); + assert.equal(received["pi-home"], "/tmp/fixture-pi-home"); + assert.equal(received["include-user-home"], true); +}); diff --git a/test/coding-agent-platform-notes.test.mjs b/test/coding-agent-platform-notes.test.mjs index 06d56e2..e179931 100644 --- a/test/coding-agent-platform-notes.test.mjs +++ b/test/coding-agent-platform-notes.test.mjs @@ -44,7 +44,8 @@ test("session diagnostics keeps the shared workflow before platform source roots assertAfter(content, "~/.cursor/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.qwen/projects", "## Platform Notes", "Sessions Diagnostics"); assertAfter(content, "~/.copilot/session-state", "## Platform Notes", "Sessions Diagnostics"); - assert.match(content, /Supported platforms: `qoder`, `codex`, `claude`, `cursor`, `qwen`, and `copilot`/); + 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, /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 e864b42..0011d56 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -20,8 +20,8 @@ Commands: repair-findings, record-fix-output, validate-canvas Project Evidence - session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, and - Copilot session evidence + session-analysis Collect and normalize Qoder, Codex, Claude, Cursor, Qwen, Copilot, + and Pi 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 66aab31..ad303e8 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/pi-prompt-template.test.mjs b/test/pi-prompt-template.test.mjs new file mode 100644 index 0000000..a03f82f --- /dev/null +++ b/test/pi-prompt-template.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const templatePath = path.join(repoRoot, "prompts", "better-harness.md"); + +// Faithful reimplementation of Pi's prompt-template `substituteArgs` argument +// grammar (from @earendil-works/pi-coding-agent): $@ / $ARGUMENTS and $1..$N +// simple forms, plus the `${@:-default}` / `${ARGUMENTS:-default}` / +// `${N:-default}` default-value form that expands to the fallback when the +// referenced argument is empty. This keeps the supported syntax exercised +// in-repo so a future template edit that breaks it fails a test. +function substituteArgs(content, args) { + const allArgs = args.join(" "); + return content.replace( + /\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$(ARGUMENTS|@|\d+)/gu, + (_match, defaultTarget, defaultValue, simple) => { + if (defaultTarget !== undefined) { + const value = defaultTarget === "@" || defaultTarget === "ARGUMENTS" + ? allArgs + : args[parseInt(defaultTarget, 10) - 1]; + return value && value.length > 0 ? value : defaultValue; + } + if (simple === "ARGUMENTS" || simple === "@") return allArgs; + return args[parseInt(simple, 10) - 1] ?? ""; + }, + ); +} + +test("the /better-harness prompt template uses a supported Pi default-value placeholder", () => { + const template = readFileSync(templatePath, "utf8"); + assert.match(template, /\$\{@:-[^}]+\}/u, "template should carry a ${@:-default} placeholder"); +}); + +test("Pi argument substitution expands the template default form with and without arguments", () => { + const template = readFileSync(templatePath, "utf8"); + const placeholder = template.match(/\$\{@:-([^}]+)\}/u); + assert.ok(placeholder, "expected a ${@:-default} placeholder in the template"); + const fallback = placeholder[1]; + + // No arguments -> the default text is used. + const empty = substituteArgs(template, []); + assert.ok(empty.includes(fallback), "no-argument expansion should include the default text"); + assert.doesNotMatch(empty, /\$\{@:-/u, "no-argument expansion should not leave the literal placeholder"); + + // Arguments -> the joined arguments replace the placeholder. + const withArgs = substituteArgs(template, ["one", "two"]); + assert.ok(withArgs.includes("one two"), "argument expansion should join the args with a space"); + assert.doesNotMatch(withArgs, /\$\{@:-/u, "argument expansion should not leave the literal placeholder"); +}); diff --git a/test/plugin-manifests.test.mjs b/test/plugin-manifests.test.mjs index 2d4701e..c69e4b6 100644 --- a/test/plugin-manifests.test.mjs +++ b/test/plugin-manifests.test.mjs @@ -171,6 +171,8 @@ test("host plugin manifests expose canonical Better Harness resources", () => { assert.equal(copilot.hooks, undefined); assert.equal(copilot.license, "MIT"); + 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"); assert.equal(qoder.license, "MIT"); assert.equal(packageJson.license, "MIT"); @@ -236,6 +238,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays ".github/plugin/", ".qoder-plugin/", "qwen-extension.json", + "prompts/", "AGENTS.md", "CHANGELOG.md", "CODE_OF_CONDUCT.md", @@ -265,6 +268,7 @@ test("npm packaging includes every host manifest while the runtime bundle stays assert.doesNotMatch(bundleScript, /"\.codex-plugin"/u); assert.doesNotMatch(bundleScript, /"\.cursor-plugin"/u); assert.doesNotMatch(bundleScript, /"\.github"/u); + assert.doesNotMatch(bundleScript, /"prompts"/u); assert.doesNotMatch(bundleScript, /"schemas"/u); const verifyScript = readText("scripts/npm-package/verify-pack.mjs"); @@ -286,6 +290,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\/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); assert.match(verifyScript, /package\/scripts\/review-trigger\/cli\.mjs/u); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 1012fda..6d4ebdf 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: "544235e08c2248f15a757bea00c584b7221e0011a84eec34b0b8515385a9b4dd", + sha256: "9e427d114cbd997e58391a8c376ae81733af09399a81fe55b44b1a765d2d879d", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "34bd94d621f400ce4a7975bbb9d9f1681b7bd41cef52f99befbfe578944b2825", + sha256: "f3551bbabff07a097d116faf4d349ab4da9596a0b3ab1dc09ff6b096657bb897", }, { label: "Harness command description", diff --git a/test/session-analysis-providers.test.mjs b/test/session-analysis-providers.test.mjs index 53abae4..2b606d5 100644 --- a/test/session-analysis-providers.test.mjs +++ b/test/session-analysis-providers.test.mjs @@ -14,6 +14,10 @@ import { CursorSessionAnalyzer, workspaceToCursorSlugVariants, } from "../scripts/session-analysis/platforms/cursor.mjs"; +import { + PiSessionAnalyzer, + workspaceToPiSessionDirVariants, +} from "../scripts/session-analysis/platforms/pi.mjs"; import { QwenSessionAnalyzer, workspaceToQwenSlugVariants, @@ -38,19 +42,23 @@ test("root dispatcher creates Claude and Cursor provider analyzers", async () => assert.ok(await createAnalyzer("cursor") instanceof CursorSessionAnalyzer); assert.ok(await createAnalyzer("qwen") instanceof QwenSessionAnalyzer); assert.ok(await createAnalyzer("copilot") instanceof CopilotSessionAnalyzer); + assert.ok(await createAnalyzer("pi") instanceof PiSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("claude") instanceof ClaudeSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("cursor") instanceof CursorSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("qwen") instanceof QwenSessionAnalyzer); assert.ok(await createCapabilityAnalyzer("copilot") instanceof CopilotSessionAnalyzer); + assert.ok(await createCapabilityAnalyzer("pi") instanceof PiSessionAnalyzer); }); test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", () => { assert.ok(workspaceToClaudeSlugVariants("/workspace/project").includes("-workspace-project")); assert.ok(workspaceToCursorSlugVariants("/workspace/project").includes("workspace-project")); assert.ok(workspaceToQwenSlugVariants("/workspace/project").includes("-workspace-project")); + assert.equal(workspaceToPiSessionDirVariants("/workspace/project").exact, "--workspace-project--"); assert.ok(workspaceToClaudeSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToCursorSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToQwenSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); + assert.ok(workspaceToPiSessionDirVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); }); test("Claude provider expands nested tool requests and results without using generated facets", async () => { @@ -704,3 +712,246 @@ test("Copilot provider ignores sessions from another workspace", async () => { assert.equal((await analyzer.discoverSessions(scope, roots)).length, 0); assert.equal(analyzer.factsSourceCoverage(scope).status, "absent"); }); + +test("Pi provider expands tool calls, tool results, and usage from v3 transcripts", async () => { + const root = await fixtureRoot("session-pi-provider-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "project"); + const sessionId = "44444444-4444-4444-8444-444444444444"; + const dirName = workspaceToPiSessionDirVariants(workspace).exact; + await writeJsonl(path.join(home, "sessions", dirName, `2026-07-20T01-00-00-000Z_${sessionId}.jsonl`), [ + { type: "session", version: 3, id: sessionId, timestamp: "2026-07-20T01:00:00.000Z", cwd: workspace }, + { + type: "message", + id: "aa1", + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "Implement the provider and run tests" }], timestamp: 1784509210000 }, + }, + { + type: "message", + id: "aa2", + parentId: "aa1", + timestamp: "2026-07-20T01:01:00.000Z", + message: { + role: "assistant", + model: "pi-fixture", + provider: "anthropic", + stopReason: "toolUse", + usage: { input: 10, output: 4, cacheRead: 2, cacheWrite: 1, totalTokens: 14, cost: { total: 0 } }, + content: [ + { type: "thinking", thinking: "inspect first" }, + { type: "text", text: "I will inspect and validate it." }, + { type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "npm test" } }, + { type: "toolCall", id: "tool-2", name: "read", arguments: { path: path.join(workspace, "package.json") } }, + ], + }, + }, + { + type: "message", + id: "aa3", + parentId: "aa2", + timestamp: "2026-07-20T01:02:00.000Z", + message: { role: "toolResult", toolCallId: "tool-1", toolName: "bash", isError: false, content: [{ type: "text", text: "3 tests passed" }] }, + }, + { + type: "message", + id: "aa4", + parentId: "aa3", + timestamp: "2026-07-20T01:03:00.000Z", + message: { role: "toolResult", toolCallId: "tool-2", toolName: "read", isError: true, content: [{ type: "text", text: "not found" }] }, + }, + { type: "model_change", id: "aa5", parentId: "aa4", timestamp: "2026-07-20T01:03:30.000Z", provider: "anthropic", modelId: "pi-fixture" }, + ]); + + const analyzer = new PiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.equal(discovery.sessions.length, 1); + assert.equal(discovery.sessions[0].sessionId, sessionId); + assert.deepEqual(discovery.sources.map((source) => source.kind), ["pi-session-jsonl"]); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { + includeCommandText: true, + includeUserText: true, + includeContent: true, + }); + assert.equal(events.filter((event) => event.type === "tool.call").length, 2); + assert.equal(events.filter((event) => event.type === "tool.result").length, 2); + assert.equal(events.find((event) => event.type === "user")?.userText, "Implement the provider and run tests"); + assert.equal(events.find((event) => event.model === "pi-fixture")?.modelUsage.inputTokens, 10); + assert.equal(events.find((event) => event.model === "pi-fixture")?.modelUsage.cacheReadInputTokens, 2); + assert.equal(events.find((event) => event.toolInvocationId === "tool-1" && event.type === "tool.call")?.commandText, "npm test"); + assert.equal(events.find((event) => event.toolInvocationId === "tool-2" && event.type === "tool.call")?.filePath, path.join(workspace, "package.json")); + assert.equal(events.find((event) => event.toolInvocationId === "tool-2" && event.type === "tool.result")?.success, false); + assert.ok(events.some((event) => event.type === "metadata.model_change")); + const insights = await analyzer.analyze({ command: "insights", workspace, home, selection: "all-eligible" }); + assert.equal(insights.insights.keySignals.usageEfficiency.coverage.responseCount, 1); + assert.equal(insights.insights.keySignals.usageEfficiency.tokenTotals.inputTokens, 10); + const facts = await analyzer.analyze({ command: "facts", workspace, home, limit: 1 }); + assert.equal(facts.kind, "session-core-facts"); + assert.equal(facts.scope.platform, "pi"); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(sessionId, "u")); + assert.doesNotMatch(JSON.stringify(facts), new RegExp(home.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); +}); + +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"); + const workspace = path.join(root, "workspace", "target"); + const dirName = workspaceToPiSessionDirVariants(workspace).exact; + await writeJsonl(path.join(home, "sessions", dirName, "2026-07-20T01-00-00-000Z_foreign.jsonl"), [ + { type: "session", version: 3, id: "foreign", timestamp: "2026-07-20T01:00:00.000Z", cwd: path.join(root, "workspace", "other") }, + { + type: "message", + id: "bb1", + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "foreign" }] }, + }, + ]); + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 0); +}); + +test("Pi provider discovers subdirectory session dirs that share the workspace prefix", async () => { + const root = await fixtureRoot("session-pi-subdir-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const subdir = path.join(workspace, "packages", "app"); + const dirName = workspaceToPiSessionDirVariants(subdir).exact; + await writeJsonl(path.join(home, "sessions", dirName, "2026-07-20T01-00-00-000Z_child.jsonl"), [ + { type: "session", version: 3, id: "55555555-5555-4555-8555-555555555555", timestamp: "2026-07-20T01:00:00.000Z", cwd: subdir }, + { + type: "message", + id: "cc1", + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "child session" }] }, + }, + ]); + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 1); +}); + +test("Pi treats a configured session directory as the exact flat JSONL directory", async () => { + const root = await fixtureRoot("session-pi-custom-dir-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const customDir = path.join(root, "shared-sessions"); + const header = (id, cwd) => ({ type: "session", version: 3, id, timestamp: "2026-07-20T01:00:00.000Z", cwd }); + const userMessage = (id, text) => ({ + type: "message", + id, + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text }] }, + }); + await writeJsonl(path.join(customDir, "2026-07-20T01-00-00-000Z_match.jsonl"), [ + header("66666666-6666-4666-8666-666666666666", workspace), + userMessage("dd1", "custom dir session"), + ]); + await writeJsonl(path.join(customDir, "2026-07-20T01-00-00-000Z_foreign.jsonl"), [ + header("foreign", path.join(root, "workspace", "other")), + userMessage("dd2", "foreign session"), + ]); + // A default-tree session must not be read while a custom directory is active. + const treeDir = workspaceToPiSessionDirVariants(workspace).exact; + await writeJsonl(path.join(home, "sessions", treeDir, "2026-07-20T01-00-00-000Z_tree.jsonl"), [ + header("77777777-7777-4777-8777-777777777777", workspace), + userMessage("dd3", "default tree session"), + ]); + + const analyzer = new PiSessionAnalyzer(); + const result = await analyzer.analyze({ command: "sources", workspace, home, "session-dir": customDir }); + assert.equal(result.sessions.length, 1); + assert.equal(result.sessions[0].sessionId, "66666666-6666-4666-8666-666666666666"); + assert.equal(result.sources[0].exists, true); +}); + +test("Pi resolves the session directory as CLI over environment over settings over default", async () => { + const root = await fixtureRoot("session-pi-precedence-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const cliDir = path.join(root, "cli-dir"); + const envDir = path.join(root, "env-dir"); + const settingsDir = path.join(root, "settings-dir"); + const header = (id) => ({ type: "session", version: 3, id, timestamp: "2026-07-20T01:00:00.000Z", cwd: workspace }); + const userMessage = (id) => ({ + type: "message", + id, + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + }); + await writeJsonl(path.join(cliDir, "a_cli-session.jsonl"), [header("cli-session"), userMessage("p1")]); + await writeJsonl(path.join(envDir, "a_env-session.jsonl"), [header("env-session"), userMessage("p2")]); + await writeJsonl(path.join(settingsDir, "a_settings-session.jsonl"), [header("settings-session"), userMessage("p3")]); + await mkdir(path.join(workspace, ".pi"), { recursive: true }); + await writeFile(path.join(workspace, ".pi", "settings.json"), JSON.stringify({ sessionDir: settingsDir })); + + const analyzer = new PiSessionAnalyzer(); + const fromSettings = await analyzer.analyze({ command: "sources", workspace, home }); + assert.deepEqual(fromSettings.sessions.map((session) => session.sessionId), ["settings-session"]); + + const previousEnv = process.env.PI_CODING_AGENT_SESSION_DIR; + try { + process.env.PI_CODING_AGENT_SESSION_DIR = envDir; + const fromEnv = await analyzer.analyze({ command: "sources", workspace, home }); + assert.deepEqual(fromEnv.sessions.map((session) => session.sessionId), ["env-session"]); + const fromCli = await analyzer.analyze({ command: "sources", workspace, home, "session-dir": cliDir }); + assert.deepEqual(fromCli.sessions.map((session) => session.sessionId), ["cli-session"]); + } finally { + if (previousEnv === undefined) delete process.env.PI_CODING_AGENT_SESSION_DIR; + else process.env.PI_CODING_AGENT_SESSION_DIR = previousEnv; + } +}); + +test("Pi keeps partial and malformed usage explicit instead of zero-filling", async () => { + const root = await fixtureRoot("session-pi-usage-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "project"); + const dirName = workspaceToPiSessionDirVariants(workspace).exact; + await writeJsonl(path.join(home, "sessions", dirName, "2026-07-20T01-00-00-000Z_usage.jsonl"), [ + { type: "session", version: 3, id: "88888888-8888-4888-8888-888888888888", timestamp: "2026-07-20T01:00:00.000Z", cwd: workspace }, + { + type: "message", + id: "u1", + parentId: null, + timestamp: "2026-07-20T01:00:10.000Z", + message: { role: "assistant", model: "pi-partial", content: [{ type: "text", text: "partial" }], usage: { output: 4 } }, + }, + { + type: "message", + id: "u2", + parentId: "u1", + timestamp: "2026-07-20T01:00:20.000Z", + message: { role: "assistant", model: "pi-malformed", content: [{ type: "text", text: "malformed" }], usage: { input: "10", cacheRead: null } }, + }, + ]); + + const analyzer = new PiSessionAnalyzer(); + 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 partial = events.find((event) => event.type === "model.response.completed"); + assert.deepEqual(partial.modelUsage, { outputTokens: 4 }); + assert.equal(Object.hasOwn(partial.modelUsage, "inputTokens"), false); + assert.equal(Object.hasOwn(partial.modelUsage, "cacheReadInputTokens"), false); + // Malformed usage fields never become zero; without one finite field there + // is no usage event at all. + assert.equal(events.filter((event) => event.type === "model.response.completed").length, 1); +}); + +test("Pi source roots stay absent without workspace-matching session directories", async () => { + const root = await fixtureRoot("session-pi-absent-root-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const foreignDir = workspaceToPiSessionDirVariants(path.join(root, "workspace", "other")).exact; + await writeJsonl(path.join(home, "sessions", foreignDir, "2026-07-20T01-00-00-000Z_foreign.jsonl"), [ + { type: "session", version: 3, id: "foreign", timestamp: "2026-07-20T01:00:00.000Z", cwd: path.join(root, "workspace", "other") }, + ]); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sources[0].exists, false); + assert.equal(result.sessions.length, 0); +}); diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index 6310547..3ea3655 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"]; +const SUPPORTED_PLATFORMS = ["qoder", "codex", "claude", "cursor", "qwen", "copilot", "pi"]; const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); const adapterMatrixPath = path.join(process.cwd(), "docs", "adapters", "README.md");