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 78ef69b..3713323 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,34 @@ Qwen Code produces a self-contained `report.html` with paired `report.md` and `findings.json`. Session evidence coverage depends on Qwen Code's available transcript paths; missing or partial evidence remains explicit. +### 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 91b0627..ee04b47 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -325,6 +325,31 @@ qwen extensions install QoderAI/better-harness Qwen Code 产出自包含的 `report.html`,并配套 `report.md` 与 `findings.json`。 会话证据覆盖范围取决于 Qwen Code 可用的会话记录路径;缺失或不完整的证据保持显式标注。 +### 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` Skill 和 +`/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..cf683a8 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 @@ -16,11 +16,13 @@ providers, real session-evidence adapters, and output modes. Canonical product judgment stays in `skills/`, `models/`, `references/`, `templates/`, and `scripts//`. -The `@qoderai/better-harness` npm package includes the Qoder, Claude Code, -Codex, Cursor, Qwen, and GitHub Copilot 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. +The `@qoderai/better-harness` npm package includes six filesystem metadata +roots for Qoder, Claude Code, Codex, Cursor, Qwen, and Copilot, plus Pi install +metadata in the existing `package.json`. The generated Qoder runtime bundle +includes only the Qoder shell, `.qoder-plugin/`; non-Qoder generated host +artifacts remain source-local. Claude Code installs its shell through the +repository's native marketplace manifest. Pi installs the repository as a pi +package through the `pi` manifest in `package.json`. | Host | Positioning | Shell | Configured Assets | Session Evidence | Default Output | Rules / Prompts | Smoke | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -30,6 +32,7 @@ through the repository's native marketplace manifest. | Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `html` render | | 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 @@ -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/adapters/contributing-new-coding-agent.md b/docs/adapters/contributing-new-coding-agent.md index 139f21f..3c2b7c4 100644 --- a/docs/adapters/contributing-new-coding-agent.md +++ b/docs/adapters/contributing-new-coding-agent.md @@ -134,7 +134,7 @@ sessions, reports, and packaging have different owners. Search for the existing host set before editing: ```bash -rg -n "qoder|codex|claude|cursor|qwen|copilot" scripts test references templates docs package.json +rg -n "qoder|codex|claude|cursor|qwen|copilot|pi" scripts test references templates docs package.json ``` Use the results as an inventory, not a replacement template. Typical registration 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/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/specs/2026-07-30-pi-host-support.md b/docs/specs/2026-07-30-pi-host-support.md new file mode 100644 index 0000000..df8c883 --- /dev/null +++ b/docs/specs/2026-07-30-pi-host-support.md @@ -0,0 +1,193 @@ +# 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. + +This contribution reaches the **Verified install/discovery** capability level: +native discovery, configured assets, session evidence, evidence-bundle routing, +and portable report routing are verified. Pi remains outside the public +Quickstart set until a full report render is validated end to end in Pi, as +required by the repository's host contribution contract. + +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. A standalone `autoload: false` entry with no + positive resource delta publishes no child resources; project + `autoload: false` entries can layer selective enable/disable deltas over the + matching user package. Per-resource allowlists, `!` exclusions, `+`/`-` + exact overrides, and `[]` (load none for normal autoload) narrow the reported + Skills and prompt templates. npm versions and git URL/ref variants use Pi's + normalized package identity, so a project declaration overrides or layers + over the matching user package instead of being reported twice. +- **PHS-AC-5 (session evidence):** `session-analysis` supports `--platform pi` + through a capability-owned platform module that reads workspace-matching + version-3 JSONL transcripts, requires the first parsed record to be the sole + authoritative session header, verifies its `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; relative custom paths + resolve from the target workspace. 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, selection + profile, and task-loop analysis through both the collector API and the + relevant CLIs. +- **PHS-AC-8 (host routing):** The canonical host adapter matrix carries a Pi + row with discovery paths, evidence sources, default output, and a smoke + command. Canonical portable HTML routing includes Pi, and Qoder remains the + only Canvas host. Public Quickstart surfaces remain unchanged until Pi has an + end-to-end report-render smoke. +- **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, flat and direct-file package Skills, normalized package identity, + transcript normalization, custom-directory and precedence resolution, + malformed/foreign header 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 the detailed Installation + sections in both READMEs document the `pi install` path without promoting Pi + into the public Quickstart set. + +## 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. +- Promote Pi to the README Quickstart list, Docusaurus home-page cards, + installation tabs, or public adapter matrix before a full report render is + validated end to end in Pi. +- 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 row to the canonical adapter matrix, keep the metadata-root + counts across the architecture, community, glossary, and concepts docs, and + route Pi through portable HTML reporting without changing the public + Quickstart set. (PHS-AC-8) +7. Document the `pi install` path in the detailed Installation sections of 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 + +Confirmed on the final integration diff: + +- `npm run check`: 913/913 tests passed, then npm tarball and Qoder runtime + bundle verification passed. +- `node --test test/agent-customize.test.mjs + test/session-analysis-providers.test.mjs test/task-loop-source.test.mjs + test/support-declarations.test.mjs`: 90/90 focused provider tests passed. +- `node scripts/doc-link-graph/cli.mjs skills/better-harness` followed by + `node --test test/doc-link-graph.test.mjs`: generated graph current and 6/6 + link-integrity tests passed. +- The externally visible [PR #25 evidence](https://github.com/QoderAI/better-harness/pull/25) + records contributor-run native Pi 0.83.0 install/discovery, real local session + facts, configured-asset inventory, and a complete evidence bundle. + +Not observed on the final integration diff: a model-driven, end-to-end +`/better-harness` report render inside an interactive Pi session. Public +Quickstart promotion therefore remains deferred. + +## 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..06c9c0f --- /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]' +--- + +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..7cd587f --- /dev/null +++ b/references/agent-customize/platforms/pi.md @@ -0,0 +1,94 @@ +# 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. Package + Skills may use `SKILL.md`, a top-level `.md` file in a declared Skill + directory, or a directly declared Markdown file. Settings-level `autoload` + and resource filters determine which package Skills and prompts are effective; + a project `autoload: false` entry can act as a delta over the matching user + package. + +## 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 source expansion is not inventoried. Settings-level + effective state is applied, but disabled child resources are omitted rather + than represented as independently inspectable assets. +- 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..56da657 --- /dev/null +++ b/scripts/agent-customize/providers/pi.mjs @@ -0,0 +1,540 @@ +import os from "node:os"; +import { readdir } from "node:fs/promises"; +import path from "node:path"; + +import { pathExists, pathStat } 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, + readMarkdownName, + readText, + sortByName, + titleCase, + withoutExtension, + 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)?.trim(); + 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; + }); +} + +// With autoload:false Pi treats resource patterns as a delta: no patterns +// change nothing, plain/+ patterns enable matches, and !/- patterns disable +// matches. A project delta can layer over the effective user package state. +function applyPiResourceDelta(items, packageRoot, baseItems, filter = []) { + const enabled = new Set(baseItems.map((item) => item.filePath)); + for (const rawPattern of filter) { + if (typeof rawPattern !== "string" || rawPattern.length === 0) continue; + const marker = /^[!+-]/u.test(rawPattern) ? rawPattern[0] : ""; + const pattern = marker ? rawPattern.slice(1) : rawPattern; + const exact = marker === "+" || marker === "-"; + const nextEnabled = marker !== "!" && marker !== "-"; + const normalized = toPosixPath(pattern); + for (const item of items) { + const candidates = piItemCandidates(item, packageRoot); + const matches = exact + ? candidates.includes(normalized) + : matchesPiPattern(pattern, candidates); + if (!matches) continue; + if (nextEnabled) enabled.add(item.filePath); + else enabled.delete(item.filePath); + } + } + return items.filter((item) => enabled.has(item.filePath)); +} + +// 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 manifestOverrides(manifest, key) { + const declared = Array.isArray(manifest?.[key]) ? manifest[key] : []; + return declared.filter((entry) => typeof entry === "string" && /^[!+-]/u.test(entry)); +} + +function normalizeGitClonePath(source) { + let value = source.trim(); + if (value.startsWith("git:")) value = value.slice("git:".length); + if (/^(?:https?|ssh|git):\/\//u.test(value)) { + try { + const url = new URL(value); + value = `${url.hostname}/${url.pathname.replace(/^\//u, "")}`; + } catch { + value = value.replace(/^(?:https?|ssh|git):\/\//u, "").replace(/^[^@/]+@/u, ""); + } + } + value = value.replace(/^[^@/]+@([^:]+):/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}/npm:${spec}`, + identity: `npm:${spec}`, + name: spec, + source, + sourceKind: "npm", + installPath: path.join(scopeRoot, "npm", "node_modules", ...spec.split("/")), + }; + } + if (source.startsWith("git:") || /^(?:https?|ssh|git):\/\//u.test(source) || /^[^@\s]+@[^:\s]+:.+/u.test(source)) { + const clonePath = normalizeGitClonePath(source); + const identity = `git:${clonePath.split(path.sep).join("/")}`; + return { + id: `pi/${scope}/${identity}`, + identity, + 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}/local:${localPath}`, + identity: `local:${localPath}`, + name: path.basename(localPath), + source, + sourceKind: "local", + installPath: localPath, + }; +} + +function manifestResourcePaths(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 piMarkdownItem(filePath, kind, scope, sourceLabel, rootForEvidence) { + const stat = await pathStat(filePath); + if (!stat?.isFile() || path.extname(filePath).toLowerCase() !== ".md") return []; + const fallback = kind === "skill" && path.basename(filePath) === "SKILL.md" + ? path.basename(path.dirname(filePath)) + : withoutExtension(filePath); + const metadata = await readMarkdownName(filePath, fallback, { useHeading: kind === "skill" }); + return [{ + id: `${scope}:${kind}:${filePath}`, + kind, + scope, + sourceLabel, + filePath, + name: fallback, + declaredName: metadata.name && metadata.name !== fallback ? metadata.name : undefined, + description: metadata.description, + evidence: evidence(filePath, rootForEvidence), + }]; +} + +async function collectPiSkillPath(resourcePath, scope, sourceLabel, packageRoot) { + const stat = await pathStat(resourcePath); + if (!stat) return []; + if (stat.isFile()) return piMarkdownItem(resourcePath, "skill", scope, sourceLabel, packageRoot); + if (!stat.isDirectory()) return []; + const nested = await collectSkillFiles(resourcePath, scope, sourceLabel, packageRoot); + let entries = []; + try { entries = await readdir(resourcePath, { withFileTypes: true }); } catch { return nested; } + const flat = (await Promise.all(entries + .filter((entry) => entry.isFile() && entry.name !== "SKILL.md" && entry.name.endsWith(".md")) + .map((entry) => piMarkdownItem(path.join(resourcePath, entry.name), "skill", scope, sourceLabel, packageRoot)))) + .flat(); + return [...nested, ...flat].sort(sortByName); +} + +async function collectPiPromptPath(resourcePath, scope, sourceLabel, packageRoot) { + const stat = await pathStat(resourcePath); + if (!stat) return []; + if (stat.isFile()) return piMarkdownItem(resourcePath, "command", scope, sourceLabel, packageRoot); + if (!stat.isDirectory()) return []; + return collectMarkdownItems(resourcePath, "command", scope, sourceLabel, packageRoot); +} + +async function collectPiPackagePlugin( + record, + scope, + entryConfig = { autoload: true, filters: {} }, + { baseConfig, installSources = [scope] } = {}, +) { + 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.identity, + rootPath: packageRoot, + scope: "plugin", + sourceLabel: displayName, + name: packageJson.name || record.name, + displayName, + description: packageJson.description || "", + publisher: { displayName: "Pi Package" }, + version: packageJson.version, + installSources, + installSource: scope, + installMatch: `pi-settings-${record.sourceKind}`, + installType: record.sourceKind, + enabled: entryConfig.autoload !== false || Boolean(baseConfig), + evidence: evidence(metadataPath, path.dirname(packageRoot)), + }; + const skillPaths = manifestResourcePaths(manifest, packageRoot, "skills", "skills"); + const promptPaths = manifestResourcePaths(manifest, packageRoot, "prompts", "prompts"); + const collectedSkills = (await Promise.all( + skillPaths.map((resourcePath) => collectPiSkillPath(resourcePath, "plugin", displayName, packageRoot)), + )).flat(); + const collectedPrompts = (await Promise.all( + promptPaths.map((resourcePath) => collectPiPromptPath(resourcePath, "plugin", displayName, packageRoot)), + )).flat(); + const skillOverrides = manifestOverrides(manifest, "skills"); + const promptOverrides = manifestOverrides(manifest, "prompts"); + const manifestSkills = applyPiResourceFilter( + collectedSkills, + packageRoot, + skillOverrides.length > 0 ? skillOverrides : undefined, + ); + const manifestPrompts = applyPiResourceFilter( + collectedPrompts, + packageRoot, + promptOverrides.length > 0 ? promptOverrides : undefined, + ); + const baseSkills = baseConfig + ? (baseConfig.autoload === false + ? applyPiResourceDelta(manifestSkills, packageRoot, [], baseConfig.filters.skills) + : applyPiResourceFilter(manifestSkills, packageRoot, baseConfig.filters.skills)) + : []; + const basePrompts = baseConfig + ? (baseConfig.autoload === false + ? applyPiResourceDelta(manifestPrompts, packageRoot, [], baseConfig.filters.prompts) + : applyPiResourceFilter(manifestPrompts, packageRoot, baseConfig.filters.prompts)) + : []; + plugin.skills = (entryConfig.autoload === false + ? applyPiResourceDelta(manifestSkills, packageRoot, baseSkills, entryConfig.filters.skills) + : applyPiResourceFilter(manifestSkills, packageRoot, entryConfig.filters.skills)).sort(sortByName); + plugin.commands = (entryConfig.autoload === false + ? applyPiResourceDelta(manifestPrompts, packageRoot, basePrompts, entryConfig.filters.prompts) + : applyPiResourceFilter(manifestPrompts, packageRoot, entryConfig.filters.prompts)).sort(sortByName); + if (entryConfig.autoload === false) { + plugin.enabled = plugin.skills.length > 0 || plugin.commands.length > 0 || Boolean(baseConfig?.autoload); + plugin.resourceState = plugin.enabled ? "selective-autoload" : "not-autoloaded"; + } + 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}`, + identity: `extension:${extensionDir}`, + 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; +} + +function collectPiPackageEntries(settings, settingsPath, scopeRoot, scope) { + const entries = Array.isArray(settings?.packages) ? settings.packages : []; + const records = []; + const seen = new Set(); + for (const entry of entries) { + const entryConfig = normalizePiPackageEntry(entry); + if (!entryConfig) continue; + const record = resolvePiPackageRecord(entryConfig.source, { + scopeRoot, + settingsDir: path.dirname(settingsPath), + scope, + }); + if (seen.has(record.identity)) continue; + seen.add(record.identity); + records.push({ record, entryConfig, scope }); + } + return records; +} + +async function collectEffectivePiPackagePlugins(userEntries, projectEntries) { + const userByIdentity = new Map(userEntries.map((entry) => [entry.record.identity, entry])); + const consumedUsers = new Set(); + const plugins = []; + for (const projectEntry of projectEntries) { + const userEntry = userByIdentity.get(projectEntry.record.identity); + if (userEntry) consumedUsers.add(projectEntry.record.identity); + const isDelta = projectEntry.entryConfig.autoload === false && userEntry; + const plugin = await collectPiPackagePlugin( + isDelta ? userEntry.record : projectEntry.record, + "project", + projectEntry.entryConfig, + isDelta + ? { baseConfig: userEntry.entryConfig, installSources: ["project", "user"] } + : { installSources: ["project"] }, + ); + if (plugin) plugins.push(plugin); + } + for (const userEntry of userEntries) { + if (consumedUsers.has(userEntry.record.identity)) continue; + const plugin = await collectPiPackagePlugin( + userEntry.record, + "user", + userEntry.entryConfig, + { installSources: ["user"] }, + ); + 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 userPackageEntries = includeUserHome + ? collectPiPackageEntries(userSettings, userSettingsPath, piHome, "user") + : []; + const projectPackageEntries = collectPiPackageEntries( + projectSettings, + projectSettingsPath, + path.join(workspace, ".pi"), + "project", + ); + const [packagePlugins, extensionPlugins, user, project] = await Promise.all([ + collectEffectivePiPackagePlugins(userPackageEntries, projectPackageEntries), + includeUserHome ? collectPiExtensionPlugins(piHome) : [], + includeUserHome ? collectPiUserPrimitives(piHome, userHome) : emptyPrimitives(), + collectPiWorkspacePrimitives(workspace), + ]); + const plugins = [...packagePlugins, ...extensionPlugins].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..b3c823e 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); } @@ -1025,12 +1036,16 @@ export async function createTaskLoopSourceFromSessions(options = {}) { const analyzerOptions = { platform, workspace: options.workspace, + home: options.home, since: selectionProfile?.scope?.since ?? options.since, until: snapshotUntil, qoderHome: options.qoderHome ?? options["qoder-home"], codexHome: options.codexHome ?? options["codex-home"], claudeHome: options.claudeHome ?? options["claude-home"], cursorHome: options.cursorHome ?? options["cursor-home"], + qwenHome: options.qwenHome ?? options["qwen-home"], + copilotHome: options.copilotHome ?? options["copilot-home"], + piHome: options.piHome ?? options["pi-home"], includeGlobalCapabilities: options.includeGlobalCapabilities ?? options["include-global-capabilities"] ?? false, diff --git a/scripts/npm-package/verify-pack.mjs b/scripts/npm-package/verify-pack.mjs index 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..45a5f35 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) @@ -44,6 +44,7 @@ Options: --cursor-home Cursor data root (default: ~/.cursor) --qwen-home Qwen Code data root (default: ~/.qwen) --copilot-home Copilot CLI data root (default: ~/.copilot) + --pi-home Pi agent data root (default: ~/.pi/agent) --include-cache Include optional Qoder cache evidence --include-global-capabilities Include optional user-global Qoder evidence @@ -273,7 +274,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 +295,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..fec09ef --- /dev/null +++ b/scripts/session-analysis/platforms/pi.mjs @@ -0,0 +1,466 @@ +#!/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, isDirectory, 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, + validHeader: false, + }; + let firstRecord = true; + await forEachJsonLine(filePath, (raw) => { + if (firstRecord) { + firstRecord = false; + // Pi requires the first parsed record to be the session header. Do not + // let a later injected header qualify an otherwise foreign transcript. + if (raw?.type !== "session" || typeof raw.id !== "string" || !isWorkspaceMatch(raw.cwd, workspace)) { + return false; + } + summary.validHeader = true; + summary.workspaceMatch = true; + summary.sessionId = raw.id; + } else if (raw?.type === "session") { + // Multiple headers are not a valid Pi session and can splice content + // from different workspaces, so reject the whole file fail-closed. + summary.validHeader = false; + summary.workspaceMatch = false; + return false; + } + 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; + } +} + +function resolvePiSessionDir(value, workspace) { + const expanded = expandHome(value); + return path.resolve(path.isAbsolute(expanded) ? expanded : path.join(workspace, expanded)); +} + +// 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: resolvePiSessionDir(cliDir, workspace) }; + } + if (process.env.PI_CODING_AGENT_SESSION_DIR) { + return { mode: "custom", dir: resolvePiSessionDir(process.env.PI_CODING_AGENT_SESSION_DIR, workspace) }; + } + const settingsDir = (await readSettingsSessionDir(path.join(workspace, ".pi", "settings.json"))) + ?? (await readSettingsSessionDir(path.join(home, "settings.json"))); + if (settingsDir) { + return { mode: "custom", dir: resolvePiSessionDir(settingsDir, workspace) }; + } + 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 matchingDirs = custom + ? [] + : await listSessionDirectories(scope.sessionsDir, scope._workspaceDirVariants); + const root = { + id: "pi-sessions", + kind: "pi-session-jsonl", + role: "session-transcript", + path: custom + ? scope.sessionsDir + : (matchingDirs[0] ?? 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 isDirectory(scope.sessionsDir) + : matchingDirs.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.validHeader || !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..ba0f315 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 @@ -43,11 +43,16 @@ export async function createSelectionProfileBundle(options = {}) { const analyzerOptions = { platform, workspace: options.workspace, + home: options.home, since: options.since, until: snapshotUntil, qoderHome: options.qoderHome ?? options["qoder-home"], codexHome: options.codexHome ?? options["codex-home"], + claudeHome: options.claudeHome ?? options["claude-home"], 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 discovery = await analyzer.analyze({ ...analyzerOptions, command: "sources" }); const scope = await analyzer.resolveScope(analyzerOptions); 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/templates/reporting/routing.md b/templates/reporting/routing.md index f1c9799..862749d 100644 --- a/templates/reporting/routing.md +++ b/templates/reporting/routing.md @@ -28,6 +28,6 @@ files from other routes. | Route | Use when | Artifacts | Runtime owner | | --- | --- | --- | --- | | Qoder Canvas report | Active host is Qoder | renderer-owned `findings.json`, `canvas.json`, `report.canvas.tsx` | `qoder-canvas.md` | -| Portable HTML report | Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | +| Portable HTML report | Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi, or a portable visual is explicitly requested | renderer-owned `findings.json`, `report.md`, `report.html` | `html-visual.md` | | Markdown only | Markdown without a visual companion is explicitly requested | `report.md`, `findings.json` | none | | Inline only | Inline or no-files output is explicitly requested | none; inline analysis writes nothing | none | diff --git a/test/agent-asset-baseline.test.mjs b/test/agent-asset-baseline.test.mjs index 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..f0fcee2 100644 --- a/test/agent-customize-architecture.test.mjs +++ b/test/agent-customize-architecture.test.mjs @@ -54,8 +54,8 @@ 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, /generated\s+Qoder runtime bundle includes only the Qoder shell/u); + assert.match(adapterReadme, /npm package includes six filesystem metadata\s+roots for Qoder, Claude Code, Codex, Cursor, Qwen, and Copilot, plus Pi install\s+metadata in the existing `package\.json`/u); + assert.match(adapterReadme, /generated\s+Qoder runtime bundle\s+includes only the Qoder shell/u); assert.match(adapterReadme, /Cursor \| Analysis-capable source-local host[^\n]+platforms\/cursor\.mjs/u); assert.doesNotMatch(adapterReadme, /Cursor has no session-evidence adapter/u); assert.match(adapterReadme, /Split a host into `docs\/adapters\/\.md` only when/u); @@ -81,6 +81,7 @@ test("host architecture docs keep matrix, providers, and thin shells separate", assert.match(adapterReadme, /Claude Code\s+\|/u); assert.match(adapterReadme, /GitHub Copilot \| Analysis-capable source-local host \| `\.github\/plugin\/`[^\n]+platforms\/copilot\.mjs/u); + assert.match(adapterReadme, /Pi \| Analysis-capable source-local host \| `pi` manifest[^\n]+platforms\/pi\.mjs/u); assert.match(directoryAdr, /\.github\/plugin\/\s+# \[active\] thin GitHub Copilot shell/u); assert.doesNotMatch(community, /`docs\/adapters\/\.md`\s+\| Discovery paths/u); assert.doesNotMatch(architecture, /Do not add `\.codex-plugin\/` until Codex has/u); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index 1009ab7..3d53828 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -1801,3 +1801,284 @@ 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 applies project autoload deltas over normalized user package identities", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-pi-delta-")); + const piHome = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace"); + try { + const packageRoot = path.join(piHome, "npm", "node_modules", "shared-pack"); + await writeJson(path.join(packageRoot, "package.json"), { name: "shared-pack", version: "1.0.0" }); + await writeText(path.join(packageRoot, "skills", "base", "SKILL.md"), "---\nname: base\ndescription: Base skill.\n---\n"); + await writeText(path.join(packageRoot, "skills", "enabled", "SKILL.md"), "---\nname: enabled\ndescription: Enabled skill.\n---\n"); + await writeText(path.join(packageRoot, "prompts", "base.md"), "# Base prompt\n"); + await writeJson(path.join(piHome, "settings.json"), { packages: ["npm:shared-pack@1.0.0"] }); + await writeJson(path.join(workspace, ".pi", "settings.json"), { + packages: [{ + source: "npm:shared-pack@2.0.0", + autoload: false, + skills: ["skills/enabled/SKILL.md", "-skills/base/SKILL.md"], + prompts: [], + }], + }); + + const inventory = await collectAgentCustomizeInventory({ provider: "pi", piHome, workspace }); + const packages = inventory.plugins.filter((plugin) => plugin.name === "shared-pack"); + assert.equal(packages.length, 1, "npm identity should ignore the configured version"); + assert.deepEqual(packages[0].installSources, ["project", "user"]); + assert.equal(packages[0].resourceState, "selective-autoload"); + assert.deepEqual(packages[0].skills.map((skill) => skill.name), ["enabled"]); + assert.deepEqual(packages[0].commands.map((command) => command.name), ["base"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Pi package inventory covers flat skills, direct manifest files, and sanitized git identities", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-agent-customize-pi-resources-")); + const piHome = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace"); + try { + const flatRoot = path.join(piHome, "npm", "node_modules", "flat-pack"); + await writeJson(path.join(flatRoot, "package.json"), { + name: "flat-pack", + version: "1.0.0", + pi: { + skills: ["./skills", "./single-skill.md", "-./skills/drop.md"], + prompts: ["./single-prompt.md"], + }, + }); + await writeText(path.join(flatRoot, "skills", "flat.md"), "---\nname: flat\ndescription: Flat skill.\n---\n"); + await writeText(path.join(flatRoot, "skills", "drop.md"), "---\nname: drop\ndescription: Excluded skill.\n---\n"); + await writeText(path.join(flatRoot, "skills", "nested", "SKILL.md"), "---\nname: nested\ndescription: Nested skill.\n---\n"); + await writeText(path.join(flatRoot, "single-skill.md"), "---\nname: single-skill\ndescription: Direct skill.\n---\n"); + await writeText(path.join(flatRoot, "single-prompt.md"), "# Single Prompt\n"); + + const gitRoot = path.join(piHome, "git", "github.com", "acme", "private-pack"); + await writeJson(path.join(gitRoot, "package.json"), { name: "private-pack", version: "1.0.0" }); + await writeText(path.join(gitRoot, "skills", "private", "SKILL.md"), "---\nname: private\ndescription: Private skill.\n---\n"); + await writeJson(path.join(piHome, "settings.json"), { + packages: [ + "npm:flat-pack", + "git:https://oauth2:secret-token@github.com/acme/private-pack.git@v1.0.0", + ], + }); + + const inventory = await collectAgentCustomizeInventory({ provider: "pi", piHome, workspace }); + const flat = inventory.plugins.find((plugin) => plugin.name === "flat-pack"); + assert.ok(flat); + assert.deepEqual(flat.skills.map((skill) => skill.name).sort(), ["flat", "nested", "single-skill"]); + assert.deepEqual(flat.commands.map((command) => command.name), ["single-prompt"]); + assert.ok(inventory.plugins.some((plugin) => plugin.name === "private-pack")); + assert.doesNotMatch(JSON.stringify(inventory), /secret-token/u); + } 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/better-harness-skill.test.mjs b/test/better-harness-skill.test.mjs index 178fe5b..e000768 100644 --- a/test/better-harness-skill.test.mjs +++ b/test/better-harness-skill.test.mjs @@ -77,10 +77,11 @@ test("non-Qoder providers default to validated durable HTML with an explicit inl assert.match(skill, /HTML artifacts: findings\.json, report\.md, report\.html/); assert.match(skill, /Succeed only on\s+`status: pass`/); assert.match(skill, /Never hand-write\s+Canvas, Markdown, or HTML/); - assert.match(routing, /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot/); + assert.match(routing, /Portable HTML report \| Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi/); assert.match(routing, /Inline only \| Inline or no-files output is explicitly requested \| none; inline analysis writes nothing/); assert.match(adapters, /Claude Code[^\n]+scripts\/session-analysis\/platforms\/claude\.mjs[^\n]+self-contained HTML \+ Markdown/); assert.match(adapters, /Cursor[^\n]+scripts\/session-analysis\/platforms\/cursor\.mjs[^\n]+self-contained HTML \+ Markdown/); + assert.match(adapters, /Pi[^\n]+scripts\/session-analysis\/platforms\/pi\.mjs[^\n]+self-contained HTML \+ Markdown/); assert.doesNotMatch(adapters, /Inline repository review only|No Claude\s+session-evidence adapter/); assert.match(readme, /Claude Code defaults to a self-contained `report\.html`/); assert.match(readme, /\.claude\/better-harness/); 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..523345b --- /dev/null +++ b/test/pi-prompt-template.test.mjs @@ -0,0 +1,54 @@ +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, /^argument-hint: '[^']+'$/mu, "argument-hint must be a YAML string for Pi autocomplete"); + 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..e29c9fa 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,301 @@ 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 requires one authoritative first session header", async () => { + const root = await fixtureRoot("session-pi-header-boundary-"); + 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, "late-header.jsonl"), [ + { type: "message", id: "x1", timestamp: "2026-07-20T01:00:00.000Z", message: { role: "user", content: "foreign" } }, + { type: "session", version: 3, id: "late", timestamp: "2026-07-20T01:00:01.000Z", cwd: workspace }, + ]); + await writeJsonl(path.join(home, "sessions", dirName, "multiple-headers.jsonl"), [ + { type: "session", version: 3, id: "spliced", timestamp: "2026-07-20T01:00:00.000Z", cwd: workspace }, + { type: "message", id: "x2", timestamp: "2026-07-20T01:00:01.000Z", message: { role: "user", content: "target" } }, + { type: "session", version: 3, id: "foreign", timestamp: "2026-07-20T01:00:02.000Z", cwd: path.join(root, "workspace", "other") }, + { type: "message", id: "x3", timestamp: "2026-07-20T01:00:03.000Z", message: { role: "user", content: "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); + assert.equal(result.sources[0].path, path.join(home, "sessions", dirName)); +}); + +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 resolves relative configured session directories from the target workspace", async () => { + const root = await fixtureRoot("session-pi-relative-dir-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const relativeDir = path.join(".pi", "custom-sessions"); + const sessionId = "relative-session"; + await writeJsonl(path.join(workspace, relativeDir, "relative.jsonl"), [ + { type: "session", version: 3, id: sessionId, timestamp: "2026-07-20T01:00:00.000Z", cwd: workspace }, + ]); + await mkdir(path.join(workspace, ".pi"), { recursive: true }); + await writeFile(path.join(workspace, ".pi", "settings.json"), JSON.stringify({ sessionDir: relativeDir })); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sources[0].path, path.join(workspace, relativeDir)); + assert.deepEqual(result.sessions.map((session) => session.sessionId), [sessionId]); +}); + +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); +}); + +test("Pi custom session roots require a directory", async () => { + const root = await fixtureRoot("session-pi-custom-root-file-"); + const home = path.join(root, ".pi", "agent"); + const workspace = path.join(root, "workspace", "target"); + const customPath = path.join(root, "not-a-directory.jsonl"); + await writeFile(customPath, "{}\n"); + + const result = await new PiSessionAnalyzer().analyze({ + command: "sources", + workspace, + home, + "session-dir": customPath, + }); + assert.equal(result.sources[0].exists, false); + assert.equal(result.sessions.length, 0); +}); diff --git a/test/style-templates.test.mjs b/test/style-templates.test.mjs index 9c2f631..c96126c 100644 --- a/test/style-templates.test.mjs +++ b/test/style-templates.test.mjs @@ -75,7 +75,7 @@ test("harness report routing owns output-mode selection and exclusions", () => { assert.match(reportRouting, /Choose exactly one output route/); assert.match(reportRouting, /Qoder Canvas report/); assert.match(reportRouting, /Portable HTML report/); - assert.match(reportRouting, /Active host is Claude Code, Codex, Cursor, Qwen Code, or GitHub Copilot/); + assert.match(reportRouting, /Active host is Claude Code, Codex, Cursor, Qwen Code, GitHub Copilot, or Pi/); assert.match(reportRouting, /Markdown only/); assert.match(reportRouting, /Inline only/); assert.match(reportRouting, /inline analysis writes nothing/); diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index 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"); diff --git a/test/task-loop-source.test.mjs b/test/task-loop-source.test.mjs index fc74a3f..4be9243 100644 --- a/test/task-loop-source.test.mjs +++ b/test/task-loop-source.test.mjs @@ -446,6 +446,7 @@ test("requested usage reuses one cutoff and the initially discovered session inv calls.push({ command: options.command, until: options.until, + piHome: options.piHome, inventory: options.sessionInventory?.map((session) => session.sessionId) ?? null, }); if (options.command === "sources") { @@ -495,12 +496,14 @@ test("requested usage reuses one cutoff and the initially discovered session inv snapshotUntil: "2026-07-17T09:00:00.000Z", includeUsage: true, qoderHome: path.join(root, ".qoder"), + piHome: path.join(root, ".pi", "agent"), practiceInventory: { summary: { practiceCoverageRows: [] }, memories: { included: false, categories: [] } }, }); assert.equal(selection.eligibleCount, 2); assert.equal(source.sessionEvents.usageActivity.sessions.total, 2); assert.equal(source.sessionEvents.usageEfficiency.selection.eligibleSessionCount, 2); assert.equal(new Set(calls.map((call) => call.until)).size, 1); + assert.ok(calls.every((call) => call.piHome === path.join(root, ".pi", "agent"))); assert.deepEqual(calls.filter((call) => call.command === "insights").map((call) => call.inventory), [ ["session-a", "session-b"], ["session-a", "session-b"],