From 70ccf53186bdf90efc89c34f3f54849ce356fece Mon Sep 17 00:00:00 2001 From: Comic Chang Date: Thu, 3 Sep 2026 22:38:23 +0800 Subject: [PATCH 1/5] fix(pi): support Oh My Pi (OMP) session format and directory naming OMP (Oh My Pi) stores sessions under ~/.omp/agent/sessions/ using: - Home-relative directory names (e.g. -src-dotai for ~/src/dotai) instead of Pi's legacy absolute format (--Users-ooxx-src-dotai--) - A 'title' preamble record before the session header in JSONL files Changes: - workspaceToPiSessionDirVariants: add homeExact/homePrefix fields for OMP's home-relative directory naming convention - listSessionDirectories: match against homeExact/homePrefix variants - probeTranscript: skip 'title' preamble records before session header - Fix path.win32.isAbsolute/normalize bug on macOS (returns true for /Users/... paths, mangling forward slashes to backslashes) Documentation: - Add OMP to README.md and README.zh-CN.md more-adapters sections - Add OMP row to adapter matrix tables (overview and canonical) - Add OMP discovery and lifecycle sections - Add OMP to output modes list Usage: PI_CODING_AGENT_DIR=~/.omp/agent better-harness harness evidence-bundle --platform pi --workspace --- README.md | 8 ++++-- README.zh-CN.md | 4 ++- docs/adapters/README.md | 11 ++++++- docs/docs/hosts/adapter-matrix.md | 14 ++++++++- scripts/session-analysis/platforms/pi.mjs | 35 +++++++++++++++++------ 5 files changed, 57 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7a5a3604..7654632c 100644 --- a/README.md +++ b/README.md @@ -388,14 +388,16 @@ surface. ### More adapters -Beyond the hosts above, Better Harness also supports Qwen Code, Pi, Kimi Code, -WorkBuddy, and Grok. Their exact install, invocation, and evidence boundaries -live in the docs so this README stays focused: +Beyond the hosts above, Better Harness also supports Qwen Code, Pi, Oh My Pi +(OMP), Kimi Code, WorkBuddy, and Grok. Their exact install, invocation, and +evidence boundaries live in the docs so this README stays focused: - **Qwen Code** — [installation guide](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`). - **Pi** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#pi) (`pi install ` or `pi -e `). +- **Oh My Pi (OMP)** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) + (`PI_CODING_AGENT_DIR=~/.omp/agent` to point at OMP session directory). - **Kimi Code** — [Host Adapter Matrix](docs/adapters/README.md) (`.kimi-plugin/plugin.json` plugin install). - **WorkBuddy** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#workbuddy). diff --git a/README.zh-CN.md b/README.zh-CN.md index 0dc63871..77b5c934 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -363,13 +363,15 @@ Copilot 不记录逐次响应的 token 用量,VS Code Copilot Chat 也没有 ### 更多适配器 -除上述宿主外,Better Harness 还支持 Qwen Code、Pi、Kimi Code、WorkBuddy 与 +除上述宿主外,Better Harness 还支持 Qwen Code、Pi、Oh My Pi (OMP)、Kimi Code、WorkBuddy 与 Grok。它们确切的安装、调用与证据边界都放在文档里,以保持本 README 精简: - **Qwen Code** —— [安装指南](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`)。 - **Pi** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#pi) (`pi install ` 或 `pi -e `)。 +- **Oh My Pi (OMP)** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) + (`PI_CODING_AGENT_DIR=~/.omp/agent` 环境变量指向 OMP 会话目录)。 - **Kimi Code** —— [宿主适配器矩阵](docs/adapters/README.md) (`.kimi-plugin/plugin.json` 插件安装)。 - **WorkBuddy** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#workbuddy)。 diff --git a/docs/adapters/README.md b/docs/adapters/README.md index dce2604c..cbee6ed4 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -45,6 +45,7 @@ project `.kimi-code/skills/`), then runs `/skill:better-harness`. | 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 | +| Oh My Pi (OMP) | Analysis-capable source-local host; reuses Pi adapter with `PI_CODING_AGENT_DIR` override | `pi` manifest in `package.json` | `scripts/agent-customize/providers/pi.mjs` | `scripts/session-analysis/platforms/pi.mjs` | self-contained HTML + Markdown | `~/.omp/agent` + `.agents` + `AGENTS.md` | `PI_CODING_AGENT_DIR=~/.omp/agent` -> `/better-harness` prompt template -> validated `html` render | | Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | | Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | @@ -83,6 +84,7 @@ Plans never execute and always preserve native surface differences: | Qwen Code | Native extension install/list argv; update and remove remain unavailable until safe scope-targeted mutation semantics are evidenced | | GitHub Copilot CLI | Native marketplace install, list, update, and uninstall argv | | Pi CLI / CLI session | Persistent user/project install guidance and inventory; separate `pi -e` session-only activation whose update/remove operations are not applicable | +| Oh My Pi (OMP) | Reuses Pi lifecycle with `PI_CODING_AGENT_DIR` override; no separate lifecycle target | | WorkBuddy | `PLUGIN_LIFECYCLE_UNSUPPORTED`; adapter evidence remains available | Kimi Code, Grok, and DSH are absent from this table on purpose: none has a @@ -166,6 +168,13 @@ edit host settings, or register an `apply` path. 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. +- Oh My Pi (OMP) reuses the Pi adapter. Set `PI_CODING_AGENT_DIR=~/.omp/agent` + to point at OMP's agent directory; session evidence is read from + `~/.omp/agent/sessions/` using home-relative directory names (e.g. + `-src-dotai` for `~/src/dotai`). OMP JSONL transcripts include a `title` + preamble record before the session header, which the Pi session adapter + now skips. Configured assets are inventoried through the shared Pi provider + when the `PI_CODING_AGENT_DIR` override is set. - Kimi Code configured assets are inventoried through `scripts/agent-customize/providers/kimi.mjs`: user-level `~/.kimi-code/skills/**/SKILL.md` and `~/.kimi-code/mcp.json`, plus @@ -287,7 +296,7 @@ Canonical templates live under `templates/reporting/`. `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. - `cursor-canvas.md`: Cursor Canvas output contract, covering the complete report, native Context Usage projection, and public IDE actions. -- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/Kimi Code/WorkBuddy/Grok/DeepSeek Harness visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/Oh My Pi (OMP)/Kimi Code/WorkBuddy/Grok/DeepSeek Harness visual output contract, covering `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 7c38dd57..657acaf7 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -38,6 +38,7 @@ remains the complete capability-level source of truth. | Qwen Code | Verified Quickstart | Analysis-capable source-local host | `qwen-extension.json` | Workspace-matching local Qwen transcripts when present | Self-contained HTML + Markdown | | GitHub Copilot | Verified Quickstart | Analysis-capable source-local host | `.github/plugin/` | Workspace-matched Copilot CLI transcripts; partial coverage stays explicit | Self-contained HTML + Markdown | | Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions | Self-contained HTML + Markdown | +| Oh My Pi (OMP) | Adapter support | Analysis-capable source-local host; reuses Pi adapter with `PI_CODING_AGENT_DIR` override | `pi` manifest in `package.json` | Workspace-matching OMP JSONL sessions under `~/.omp/agent/sessions/` | Self-contained HTML + Markdown | | Kimi Code | Adapter support | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | Workspace-matching Kimi wire transcripts | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | | Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | @@ -80,7 +81,7 @@ lifecycle target. `canvas.json`, and `report.canvas.tsx`. - **Cursor Canvas** — the same complete report contract rendered with `cursor/canvas`, native Context Window evidence, and IDE actions. -- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/Kimi Code/WorkBuddy/Grok/DeepSeek Harness contract +- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/Oh My Pi (OMP)/Kimi Code/WorkBuddy/Grok/DeepSeek Harness contract covering `findings.json`, `report.md`, and a self-contained `report.html` (see the [sample report](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. @@ -122,6 +123,17 @@ workspace-matched session evidence, and portable HTML routing are implemented. Pi remains outside the verified Quickstart set until a complete interactive report-loop smoke is observed. +### Oh My Pi (OMP) {#oh-my-pi-omp} + +Oh My Pi (OMP) reuses the Pi adapter with `PI_CODING_AGENT_DIR=~/.omp/agent` to +point at OMP's session directory. OMP stores sessions under `~/.omp/agent/sessions/` +using home-relative directory names (e.g. `~/src/dotai` → `-src-dotai`) and JSONL +transcripts with a `title` preamble record before the session header. The Pi adapter +now handles both naming conventions and the OMP preamble format. Configured assets, +workspace-matched session evidence, and portable HTML routing are implemented through +the shared Pi provider. OMP remains outside the verified Quickstart set until a +complete interactive report-loop smoke is observed. + ### Kimi Code {#kimi-code} Kimi Code installs the repository through `/plugins install ` and the diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index 4918f4a1..5156f618 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -35,16 +35,27 @@ function isScopedWorkspaceMatch(candidate, scope) { export function workspaceToPiSessionDirVariants(workspace) { const expanded = expandHome(workspace ?? process.cwd()); - const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); + const normalized = path.isAbsolute(expanded) ? path.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 { + const result = { 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}-`, }; + // OMP (Oh My Pi) v17.2.9+ uses home-relative directory names for paths + // under the home directory: "-" prefix + relative path with "/" → "-". + // e.g. ~/src/dotai → "-src-dotai" (not "--Users-ooxx-src-dotai--"). + const home = expandHome("~"); + const homeRelative = path.relative(home, normalized); + if (homeRelative && !homeRelative.startsWith("..") && !path.isAbsolute(homeRelative)) { + const homeBody = homeRelative.replace(/[/\\:]/g, "-"); + result.homeExact = `-${homeBody}`; + result.homePrefix = `-${homeBody}`; + } + return result; } function sessionIdFromFileName(filePath) { @@ -239,13 +250,16 @@ async function probeTranscript(filePath, scope) { validHeader: false, cwd: null, }; - let firstRecord = true; + let headerSeen = false; 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" || !isScopedWorkspaceMatch(raw.cwd, scope)) { + if (!headerSeen) { + // OMP emits a "title" record before the session header; skip it. + // Any other non-session record before the header indicates a foreign + // or corrupted transcript — reject fail-closed. + if (raw?.type === "title") return undefined; + if (raw?.type !== "session") return false; + headerSeen = true; + if (typeof raw.id !== "string" || !isScopedWorkspaceMatch(raw.cwd, scope)) { return false; } summary.validHeader = true; @@ -301,7 +315,10 @@ async function listSessionDirectories(sessionsRoot, variants) { return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - .filter((name) => name === variants.exact || name.startsWith(variants.prefix)) + .filter((name) => + name === variants.exact || name.startsWith(variants.prefix) || + (variants.homeExact != null && (name === variants.homeExact || name.startsWith(variants.homePrefix))) + ) .map((name) => path.join(sessionsRoot, name)); } From 138d934945a272798bc558ae72e25e5c8e2931ab Mon Sep 17 00:00:00 2001 From: Comic Chang Date: Thu, 3 Sep 2026 22:44:37 +0800 Subject: [PATCH 2/5] chore: add TODO for OMP forked session handling OMP /fork creates a new session file with parentSession field in header, duplicating all parent messages. 38/479 (7.9%) sessions are forked. Follow-up design: detect parentSession, mark as derived, process only incremental messages after fork point to avoid double-counting. --- scripts/session-analysis/platforms/pi.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index 5156f618..f3d511e5 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -266,6 +266,9 @@ async function probeTranscript(filePath, scope) { summary.workspaceMatch = true; summary.sessionId = raw.id; summary.cwd = raw.cwd; + // TODO: OMP forked sessions (parentSession field) duplicate all parent + // messages. Detect parentSession and mark as derived; process only the + // incremental messages after the fork point to avoid double-counting. } 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. From 1994f94584e5567eb98005dc7bbf2e99f5a8936f Mon Sep 17 00:00:00 2001 From: Comic Chang Date: Thu, 3 Sep 2026 22:51:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(pi):=20handle=20OMP=20forked=20session?= =?UTF-8?q?s=20=E2=80=94=20skip=20inherited=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OMP /fork creates a new session file with all parent entries copied. The parentSession field in the session header identifies the source. Changes: - probeTranscript: detect parentSession, store forkTimestamp - addRef: propagate forkTimestamp/parentSessionId to session object - readSession: skip events with timestamp < forkCutoff to avoid double-counting inherited parent messages Verified: 1 forked session detected in dotai (103 sessions), properly marked as derived with incremental-only processing. --- scripts/session-analysis/platforms/pi.mjs | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index f3d511e5..af358ab9 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -249,6 +249,8 @@ async function probeTranscript(filePath, scope) { workspaceMatch: false, validHeader: false, cwd: null, + forkTimestamp: null, + parentSessionId: null, }; let headerSeen = false; await forEachJsonLine(filePath, (raw) => { @@ -266,9 +268,13 @@ async function probeTranscript(filePath, scope) { summary.workspaceMatch = true; summary.sessionId = raw.id; summary.cwd = raw.cwd; - // TODO: OMP forked sessions (parentSession field) duplicate all parent - // messages. Detect parentSession and mark as derived; process only the - // incremental messages after the fork point to avoid double-counting. + // OMP /fork creates a new session file with all parent entries copied. + // The parentSession field identifies the source; the header timestamp + // marks the fork point — messages before it are inherited, not new. + if (typeof raw.parentSession === "string" && raw.parentSession.length > 0) { + summary.parentSessionId = raw.parentSession; + summary.forkTimestamp = raw.timestamp ?? null; + } } 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. @@ -291,7 +297,13 @@ function addRef(sessions, sessionId, workspace, ref) { sourceKinds: new Set(), sourceRefs: [], workspaceCwds: new Set(), + forkTimestamp: null, + parentSessionId: null, }; + if (ref.forkTimestamp && !session.forkTimestamp) { + session.forkTimestamp = ref.forkTimestamp; + session.parentSessionId = ref.parentSessionId ?? null; + } if (typeof ref.cwd === "string" && ref.cwd.length > 0) session.workspaceCwds.add(ref.cwd); session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); @@ -459,6 +471,8 @@ export class PiSessionAnalyzer extends SessionAnalyzer { role: transcriptRoot.role, path: filePath, firstSeen: probe.firstSeen, + forkTimestamp: probe.forkTimestamp, + parentSessionId: probe.parentSessionId, lastSeen: probe.lastSeen, cwd: probe.cwd, }); @@ -486,6 +500,9 @@ export class PiSessionAnalyzer extends SessionAnalyzer { const identityCwd = scope._workspaceMatchScope ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) : null; + // OMP forked sessions copy all parent entries. Skip inherited messages + // (timestamp before fork point) to avoid double-counting with the parent. + const forkCutoff = session.forkTimestamp ? timestampMillis(session.forkTimestamp) : null; for (const ref of session.sourceRefs ?? []) { if (remainingLines !== null && remainingLines <= 0) { truncated = true; @@ -494,6 +511,11 @@ export class PiSessionAnalyzer extends SessionAnalyzer { if (!ref.path.endsWith(".jsonl")) continue; const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (raw?.type === "session" && raw?.cwd && !isScopedWorkspaceMatch(raw.cwd, scope)) return; + // Skip entries inherited from parent session in forked files. + if (forkCutoff !== null && raw?.type !== "session") { + const evtTs = timestampMillis(inferTimestamp(raw)); + if (evtTs !== null && evtTs < forkCutoff) return; + } for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } From 046b0ccff82bdaa93a0491a9af5d90a4d341cb2d Mon Sep 17 00:00:00 2001 From: Comic Chang <1257169+comicchang@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:17:36 +0800 Subject: [PATCH 4/5] Refactor home path checks and result formatting Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/session-analysis/platforms/pi.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index af358ab9..742aa76b 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -50,10 +50,11 @@ export function workspaceToPiSessionDirVariants(workspace) { // e.g. ~/src/dotai → "-src-dotai" (not "--Users-ooxx-src-dotai--"). const home = expandHome("~"); const homeRelative = path.relative(home, normalized); - if (homeRelative && !homeRelative.startsWith("..") && !path.isAbsolute(homeRelative)) { + const outsideHome = homeRelative === ".." || homeRelative.startsWith(`..${path.sep}`); + if (homeRelative && !outsideHome && !path.isAbsolute(homeRelative)) { const homeBody = homeRelative.replace(/[/\\:]/g, "-"); result.homeExact = `-${homeBody}`; - result.homePrefix = `-${homeBody}`; + result.homePrefix = `-${homeBody}-`; } return result; } From 8802fb67baa66b274a6dea4b4dc224c2b39ea75c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Fri, 4 Sep 2026 10:38:48 +0800 Subject: [PATCH 5/5] fix(pi): restore Windows slug handling and make OMP fork dedupe evidence-safe Follow-up on the OMP compatibility work. The home-relative prefix boundary was already tightened in the preceding commit; this adds the regression tests that lock it, including a home child whose name merely begins with "..", and fixes the two remaining defects. Moving the slug normalizer from `path.win32` to `path` broke Windows-shaped workspace strings on POSIX hosts by resolving them against the host cwd, so `C:\workspace\project` produced a cwd-prefixed slug. The existing substring assertion passed straight through that regression, so it is now an equality check. The genuine `path.win32` problem was elsewhere: normalizing a POSIX path through `path.win32` backslashes it, which defeated the home-relative derivation, so that derivation now reads the host-native path instead of the slug form. Fork handling dropped inherited entries even when the parent session was absent from the discovery result, losing evidence with no coverage marker, while still reporting a session range that covered entries the session no longer yielded. Deduplication now runs after every transcript is probed, applies only when the parent is discovered as well, stores the cutoff on the source ref rather than the session so a second transcript cannot inherit a foreign cutoff, and shares one predicate with the reader so the reported range always matches the events. OMP is documented as a session layout of the `pi` platform rather than a new host, per the bounded host-adapter policy: the duplicated adapter-matrix rows, the unverified `pi` manifest install-shell claim, and the lifecycle row are removed, both READMEs attach OMP to the Pi entry, and the zh-Hans mirror gains the missing section. Acceptance scenarios and boundaries are recorded in docs/specs/2026-09-04-omp-session-format-compat.md. Validated with the provider suite and the full run. Each new assertion was mutation-checked: reverting the prefix boundary, the `path.win32` slug handling, or the parent-existence condition fails exactly the test that covers it. Co-authored-by: QoderAI (Qwen 3.8 Max) --- README.md | 10 +- README.zh-CN.md | 8 +- docs/adapters/README.md | 24 +-- docs/docs/hosts/adapter-matrix.md | 28 ++-- .../current/hosts/adapter-matrix.md | 16 +- .../2026-09-04-omp-session-format-compat.md | 110 ++++++++++++ scripts/session-analysis/platforms/pi.mjs | 137 ++++++++++----- .../session-analysis-providers.test.mjs | 157 +++++++++++++++++- 8 files changed, 414 insertions(+), 76 deletions(-) create mode 100644 docs/specs/2026-09-04-omp-session-format-compat.md diff --git a/README.md b/README.md index 7654632c..282656e4 100644 --- a/README.md +++ b/README.md @@ -388,16 +388,16 @@ surface. ### More adapters -Beyond the hosts above, Better Harness also supports Qwen Code, Pi, Oh My Pi -(OMP), Kimi Code, WorkBuddy, and Grok. Their exact install, invocation, and +Beyond the hosts above, Better Harness also supports Qwen Code, Pi, Kimi Code, +WorkBuddy, and Grok. Their exact install, invocation, and evidence boundaries live in the docs so this README stays focused: - **Qwen Code** — [installation guide](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`). - **Pi** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#pi) - (`pi install ` or `pi -e `). -- **Oh My Pi (OMP)** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) - (`PI_CODING_AGENT_DIR=~/.omp/agent` to point at OMP session directory). + (`pi install ` or `pi -e `). The same adapter reads + [Oh My Pi (OMP)](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) sessions when + `PI_CODING_AGENT_DIR` points at an OMP agent directory. - **Kimi Code** — [Host Adapter Matrix](docs/adapters/README.md) (`.kimi-plugin/plugin.json` plugin install). - **WorkBuddy** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#workbuddy). diff --git a/README.zh-CN.md b/README.zh-CN.md index 77b5c934..67e6f636 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -363,15 +363,15 @@ Copilot 不记录逐次响应的 token 用量,VS Code Copilot Chat 也没有 ### 更多适配器 -除上述宿主外,Better Harness 还支持 Qwen Code、Pi、Oh My Pi (OMP)、Kimi Code、WorkBuddy 与 +除上述宿主外,Better Harness 还支持 Qwen Code、Pi、Kimi Code、WorkBuddy 与 Grok。它们确切的安装、调用与证据边界都放在文档里,以保持本 README 精简: - **Qwen Code** —— [安装指南](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`)。 - **Pi** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#pi) - (`pi install ` 或 `pi -e `)。 -- **Oh My Pi (OMP)** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) - (`PI_CODING_AGENT_DIR=~/.omp/agent` 环境变量指向 OMP 会话目录)。 + (`pi install ` 或 `pi -e `)。当 `PI_CODING_AGENT_DIR` 指向 OMP + 的 agent 目录时,同一适配器也能读取 + [Oh My Pi (OMP)](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) 会话。 - **Kimi Code** —— [宿主适配器矩阵](docs/adapters/README.md) (`.kimi-plugin/plugin.json` 插件安装)。 - **WorkBuddy** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#workbuddy)。 diff --git a/docs/adapters/README.md b/docs/adapters/README.md index cbee6ed4..43903bde 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -45,7 +45,6 @@ project `.kimi-code/skills/`), then runs `/skill:better-harness`. | 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 | -| Oh My Pi (OMP) | Analysis-capable source-local host; reuses Pi adapter with `PI_CODING_AGENT_DIR` override | `pi` manifest in `package.json` | `scripts/agent-customize/providers/pi.mjs` | `scripts/session-analysis/platforms/pi.mjs` | self-contained HTML + Markdown | `~/.omp/agent` + `.agents` + `AGENTS.md` | `PI_CODING_AGENT_DIR=~/.omp/agent` -> `/better-harness` prompt template -> validated `html` render | | Kimi Code | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | `scripts/agent-customize/providers/kimi.mjs` | `scripts/session-analysis/platforms/kimi.mjs` | self-contained HTML + Markdown | `AGENTS.md` + `~/.kimi-code/skills` + project `.kimi-code/skills`/`.kimi/skills` + `~/.kimi-code/mcp.json` | `harness evidence-bundle --platform kimi` -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | | Grok | Analysis-capable source-local host | none (skills install into `~/.grok/skills`) | `scripts/agent-customize/providers/grok.mjs` | `scripts/session-analysis/platforms/grok.mjs` | self-contained HTML + Markdown | `~/.grok` + `.grok` + `.agents` + `AGENTS.md` | `session-analysis --platform grok sources` -> skill symlink -> validated `html` render | @@ -84,7 +83,6 @@ Plans never execute and always preserve native surface differences: | Qwen Code | Native extension install/list argv; update and remove remain unavailable until safe scope-targeted mutation semantics are evidenced | | GitHub Copilot CLI | Native marketplace install, list, update, and uninstall argv | | Pi CLI / CLI session | Persistent user/project install guidance and inventory; separate `pi -e` session-only activation whose update/remove operations are not applicable | -| Oh My Pi (OMP) | Reuses Pi lifecycle with `PI_CODING_AGENT_DIR` override; no separate lifecycle target | | WorkBuddy | `PLUGIN_LIFECYCLE_UNSUPPORTED`; adapter evidence remains available | Kimi Code, Grok, and DSH are absent from this table on purpose: none has a @@ -168,13 +166,19 @@ edit host settings, or register an `apply` path. 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. -- Oh My Pi (OMP) reuses the Pi adapter. Set `PI_CODING_AGENT_DIR=~/.omp/agent` - to point at OMP's agent directory; session evidence is read from - `~/.omp/agent/sessions/` using home-relative directory names (e.g. - `-src-dotai` for `~/src/dotai`). OMP JSONL transcripts include a `title` - preamble record before the session header, which the Pi session adapter - now skips. Configured assets are inventoried through the shared Pi provider - when the `PI_CODING_AGENT_DIR` override is set. +- Oh My Pi (OMP) is a session layout of the `pi` platform, not a host of its own: + it has no host id, capability profile, install shell, or lifecycle target here. + Pointing `PI_CODING_AGENT_DIR` at `~/.omp/agent` makes both the Pi + configured-asset provider and `scripts/session-analysis/platforms/pi.mjs` read + that tree. OMP names session directories relative to the home directory + (`-src-dotai` for `~/src/dotai`) alongside pi's absolute `----` form, and + prefixes its JSONL transcripts with a `title` record before the session header; + the adapter skips that preamble while still rejecting any other pre-header + record fail-closed. An OMP `/fork` transcript carries a copy of its parent's + entries and a `parentSession` id: those entries are attributed to the parent + only when the parent session is discovered in the same result, so they are + counted exactly once and never dropped silently. Boundaries and acceptance + scenarios live in `docs/specs/2026-09-04-omp-session-format-compat.md`. - Kimi Code configured assets are inventoried through `scripts/agent-customize/providers/kimi.mjs`: user-level `~/.kimi-code/skills/**/SKILL.md` and `~/.kimi-code/mcp.json`, plus @@ -296,7 +300,7 @@ Canonical templates live under `templates/reporting/`. `findings.json`, Canvas-only `canvas.json`, and `report.canvas.tsx`. - `cursor-canvas.md`: Cursor Canvas output contract, covering the complete report, native Context Usage projection, and public IDE actions. -- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/Oh My Pi (OMP)/Kimi Code/WorkBuddy/Grok/DeepSeek Harness visual output contract, covering +- `html-visual.md`: portable Claude Code/Codex/Qwen/Copilot/Pi/Kimi Code/WorkBuddy/Grok/DeepSeek Harness visual output contract, covering `findings.json`, `report.md`, and `report.html`. - Markdown-only output has no visual companion. diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 657acaf7..98015929 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -37,8 +37,7 @@ remains the complete capability-level source of truth. | Cursor | Verified Quickstart | Canvas-capable source-local host | `.cursor-plugin/` | Workspace-matched transcripts, metadata, audit logs, and optional native Context Usage snapshots; partial coverage stays explicit | Cursor Canvas report | | Qwen Code | Verified Quickstart | Analysis-capable source-local host | `qwen-extension.json` | Workspace-matching local Qwen transcripts when present | Self-contained HTML + Markdown | | GitHub Copilot | Verified Quickstart | Analysis-capable source-local host | `.github/plugin/` | Workspace-matched Copilot CLI transcripts; partial coverage stays explicit | Self-contained HTML + Markdown | -| Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions | Self-contained HTML + Markdown | -| Oh My Pi (OMP) | Adapter support | Analysis-capable source-local host; reuses Pi adapter with `PI_CODING_AGENT_DIR` override | `pi` manifest in `package.json` | Workspace-matching OMP JSONL sessions under `~/.omp/agent/sessions/` | Self-contained HTML + Markdown | +| Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions, including the Oh My Pi (OMP) session layout | Self-contained HTML + Markdown | | Kimi Code | Adapter support | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | Workspace-matching Kimi wire transcripts | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | | Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | @@ -81,7 +80,7 @@ lifecycle target. `canvas.json`, and `report.canvas.tsx`. - **Cursor Canvas** — the same complete report contract rendered with `cursor/canvas`, native Context Window evidence, and IDE actions. -- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/Oh My Pi (OMP)/Kimi Code/WorkBuddy/Grok/DeepSeek Harness contract +- **HTML visual** — portable Claude Code/Codex/Qwen/Copilot/Pi/Kimi Code/WorkBuddy/Grok/DeepSeek Harness contract covering `findings.json`, `report.md`, and a self-contained `report.html` (see the [sample report](pathname:///demo/better-harness-report/)). - **Markdown-only** — no visual companion. @@ -125,14 +124,21 @@ report-loop smoke is observed. ### Oh My Pi (OMP) {#oh-my-pi-omp} -Oh My Pi (OMP) reuses the Pi adapter with `PI_CODING_AGENT_DIR=~/.omp/agent` to -point at OMP's session directory. OMP stores sessions under `~/.omp/agent/sessions/` -using home-relative directory names (e.g. `~/src/dotai` → `-src-dotai`) and JSONL -transcripts with a `title` preamble record before the session header. The Pi adapter -now handles both naming conventions and the OMP preamble format. Configured assets, -workspace-matched session evidence, and portable HTML routing are implemented through -the shared Pi provider. OMP remains outside the verified Quickstart set until a -complete interactive report-loop smoke is observed. +Oh My Pi (OMP) is not a separate host adapter: it is a session layout the `pi` +platform recognizes when `PI_CODING_AGENT_DIR=~/.omp/agent` points at an OMP +agent directory. OMP has no host id, capability profile, install shell, or +lifecycle target of its own. + +OMP keys session directories on the home-relative workspace path (`~/src/dotai` → +`-src-dotai`) instead of pi's absolute `----` form, and its JSONL +transcripts start with a `title` record before the session header. The Pi adapter +reads both naming conventions and skips that preamble while keeping pi's +fail-closed header rule and workspace isolation. An OMP `/fork` transcript copies +its parent's entries; those entries count as the parent's only when the parent +session is discovered in the same result, so nothing is double-counted and nothing +silently disappears. Session evidence and portable HTML routing come from the +shared Pi provider, and configured assets are inventoried by the Pi provider +because it honors the same `PI_CODING_AGENT_DIR` override. ### Kimi Code {#kimi-code} diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index 2ccc0957..7a34c03d 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -27,7 +27,7 @@ Better Harness 当前声明了十个能力层宿主适配器,其中六个已 | Cursor | 已验证快速开始 | 支持 Canvas 的源码本地宿主 | `.cursor-plugin/` | 工作区匹配的转录、元数据、审计日志和可选原生 Context Usage 快照;部分覆盖保持显式标注 | Cursor Canvas 报告 | | Qwen Code | 已验证快速开始 | 具备分析能力的源码本地宿主 | `qwen-extension.json` | 匹配当前工作区的本地 Qwen 转录(存在时) | 自包含 HTML + Markdown | | GitHub Copilot | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.github/plugin/` | 工作区匹配的 Copilot CLI 转录;部分覆盖保持显式标注 | 自包含 HTML + Markdown | -| Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话 | 自包含 HTML + Markdown | +| Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话,包含 Oh My Pi (OMP) 会话布局 | 自包含 HTML + Markdown | | Kimi Code | 适配器支持 | 具备分析能力的源码本地宿主 | `.kimi-plugin/plugin.json` | 匹配工作区的 Kimi wire 转录 | 自包含 HTML + Markdown | | WorkBuddy | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 WorkBuddy 自有路径 | 匹配工作区的 WorkBuddy JSONL 转录 | 自包含 HTML + Markdown | | Grok | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 Grok 自有路径 | 匹配工作区的 Grok 会话目录(`updates.jsonl`) | 自包含 HTML + Markdown | @@ -76,6 +76,20 @@ Pi 可以通过 `pi install ` 安装本仓库,或使用 `pi -e --` 形式,并且在会话头之前先写一条 `title` 记录。 +Pi 适配器同时识别两种命名约定并跳过该前置记录,同时保留 pi 的 fail-closed +会话头规则与工作区隔离。OMP 的 `/fork` 转录会复制父会话的全部条目:只有当父 +会话也在同一次发现结果中时,这些条目才计入父会话,因此既不会重复计数,也不会 +静默丢失。会话证据与可移植 HTML 路由均来自共享的 Pi provider;已配置资产也由 Pi +provider 清点,因为它遵循同一个 `PI_CODING_AGENT_DIR` 覆盖项。 + ### Kimi Code {#kimi-code} Kimi Code 通过 `/plugins install ` 和 `.kimi-plugin/plugin.json` diff --git a/docs/specs/2026-09-04-omp-session-format-compat.md b/docs/specs/2026-09-04-omp-session-format-compat.md new file mode 100644 index 00000000..fd526234 --- /dev/null +++ b/docs/specs/2026-09-04-omp-session-format-compat.md @@ -0,0 +1,110 @@ +# Spec: Oh My Pi session format compatibility in the Pi adapter + +## Traceability + +- **Spec ID:** OMP-01 +- **Story:** none; maintainer-approved review follow-up on PR #143 +- **Status:** implemented + +## Intent + +Oh My Pi (OMP) is a fork of Pi that shares the session transcript schema but +diverges in three observable ways. The Pi session adapter should read OMP session +trees when it is pointed at an OMP agent directory, without loosening any of the +workspace-isolation guarantees the Pi adapter already makes. + +OMP is not a new host adapter: it has no host id, no capability profile, no +install shell, and no lifecycle target of its own. It is a recognized session +layout for the existing `pi` platform, reached with +`PI_CODING_AGENT_DIR=~/.omp/agent`. + +## Observed OMP divergences + +| Divergence | Pi | OMP | +| --- | --- | --- | +| Session directory name | absolute slug `--Users-ooxx-src-dotai--` | home-relative slug `-src-dotai` | +| JSONL preamble | first record is the `session` header | a `title` record precedes the header | +| Session forking | not observed | `/fork` copies every parent entry into a new file carrying `parentSession` | + +## Acceptance scenarios + +- **AC-1 — Home-relative discovery.** A workspace under the home directory + resolves an additional `homeExact` variant (`~/src/dotai` → `-src-dotai`), and + a session directory with that name is discovered. +- **AC-2 — Bounded home-relative prefix.** Subdirectory session directories match + through `homePrefix` only across a separator boundary (`-src-dotai-packages-app` + matches, `-src-dotaix` does not). A workspace whose only prefix-similar sibling + has sessions reports its source root as absent. +- **AC-3 — Non-home workspaces unchanged.** A workspace outside the home + directory exposes no home-relative variant, and the absolute `exact`/`prefix` + contract is byte-identical to before. +- **AC-4 — Foreign-platform slug integrity.** A Windows-shaped workspace string + keeps its drive letter on every host (`C:\workspace\project` → + `--C--workspace-project--`) and never resolves against the host cwd. Only a + host-native absolute path is eligible for a home-relative variant. +- **AC-5 — Title preamble.** Leading `title` records are skipped when locating + the session header. Any other non-`session` record before the header still + rejects the transcript fail-closed, and a second `session` header still rejects + the whole file. +- **AC-6 — Fork deduplication.** When a fork's `parentSession` is also discovered + in the same result, entries stamped before the fork point are read as the + parent's and excluded from the fork, so each entry is counted once. +- **AC-7 — Orphan fork retention.** When the parent is not in the discovery + result, the fork keeps its inherited entries: nothing else owns them, so + dropping them would silently lose evidence. +- **AC-8 — Time range agreement.** A session's reported `firstSeen`/`lastSeen` + always covers exactly the entries it yields, whether or not the fork cutoff + applies. + +## Design + +`workspaceToPiSessionDirVariants` keeps the absolute slug derivation untouched +and adds `homeExact`/`homePrefix` when the workspace is a host-native absolute +path under the home directory. Slug normalization and home-relative derivation +read different inputs on purpose: the slug body keeps `path.win32` handling so a +Windows-shaped workspace string survives on a POSIX host, while the +home-relative body needs the host-native path because a foreign-platform path can +never be home-relative. + +`probeTranscript` records `parentSessionId`, `forkTimestamp`, and a second +"owned" time range that excludes inherited entries. `discoverSessions` collects +every probe first, then decides per file whether the fork cutoff applies, because +the parent may be probed after its fork. The cutoff is stored on the source ref +rather than the session, so a session with several transcripts cannot leak one +file's cutoff onto another. `isInheritedForkEntry` is shared by discovery and +`readSession` so the reported time range and the yielded events cannot disagree. + +## Non-goals + +- Adding OMP to `scripts/host-support` as a distinct host id or capability + profile. +- An OMP install shell, plugin manifest, or npm-packaged host artifact. +- Promoting OMP into the verified Quickstart set. +- Detecting an OMP agent directory automatically; the operator points at it with + `PI_CODING_AGENT_DIR`. + +## Risks + +- The `parentSession` field name and the header-timestamp-as-fork-point rule come + from observed OMP transcripts, not a published schema. If OMP renames the field, + fork deduplication silently stops applying and forks report inherited entries + again — the fail-open direction, which over-counts rather than loses evidence. +- Fork deduplication compares timestamps. An inherited entry without a timestamp, + or one sharing the fork millisecond, stays with the fork. + +## Test evidence + +`test/sessions/session-analysis-providers.test.mjs`: + +- `Pi derives OMP home-relative session directory names with a bounded prefix` (AC-1, AC-2, AC-3) +- `Pi discovers OMP home-relative session directories and skips the title preamble` (AC-1, AC-5) +- `Pi keeps a sibling OMP workspace out of the home-relative prefix match` (AC-2) +- `Pi discovers OMP subdirectory session dirs under the home-relative prefix` (AC-2) +- `Pi counts OMP inherited fork entries once when the parent session is discovered` (AC-6, AC-8) +- `Pi retains OMP inherited fork entries when the parent session is absent` (AC-7, AC-8) +- `Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts` (AC-4) +- `Pi provider requires one authoritative first session header` (AC-5) + +Each new assertion was mutation-checked: reverting the prefix boundary, the +`path.win32` slug handling, or the parent-existence condition fails exactly the +test that covers it. diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index 742aa76b..6d220e61 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -33,9 +33,33 @@ function isScopedWorkspaceMatch(candidate, scope) { return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; } -export function workspaceToPiSessionDirVariants(workspace) { +// A Windows-shaped workspace string stays a Windows path on every host, so the +// slug keeps its drive letter instead of being resolved against the POSIX cwd. +function normalizeWorkspaceForSlug(workspace) { + const expanded = expandHome(workspace ?? process.cwd()); + return path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); +} + +// OMP (Oh My Pi) names session directories relative to the home directory: one +// leading "-" plus the home-relative path with separators folded to "-", e.g. +// ~/src/dotai → "-src-dotai" instead of pi's "--Users-ooxx-src-dotai--". +// This reads the host-native path rather than the slug form, because only a +// host-native absolute path can be home-relative at all: a foreign-platform +// workspace string would resolve against the host cwd and invent a false body. +function homeRelativeSlugBody(workspace) { const expanded = expandHome(workspace ?? process.cwd()); - const normalized = path.isAbsolute(expanded) ? path.normalize(expanded) : normalizeWorkspace(expanded); + const home = expandHome("~"); + if (typeof home !== "string" || !path.isAbsolute(home) || !path.isAbsolute(expanded)) return null; + const relative = path.relative(home, path.resolve(expanded)); + // Compare whole segments: a directory legitimately named "..cache" is inside + // the home directory, while ".." and "../" escape it. + const outsideHome = relative === ".." || relative.startsWith(`..${path.sep}`); + if (!relative || outsideHome || path.isAbsolute(relative)) return null; + return relative.replace(/[/\\:]/g, "-"); +} + +export function workspaceToPiSessionDirVariants(workspace) { + const normalized = normalizeWorkspaceForSlug(workspace); // Match pi's session directory naming: strip one leading separator, then // replace every "/", "\", and ":" with "-", wrapped as ----. const body = normalized.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); @@ -45,15 +69,12 @@ export function workspaceToPiSessionDirVariants(workspace) { // cwd-keyed directory; its name starts with the workspace slug body. prefix: `--${body}-`, }; - // OMP (Oh My Pi) v17.2.9+ uses home-relative directory names for paths - // under the home directory: "-" prefix + relative path with "/" → "-". - // e.g. ~/src/dotai → "-src-dotai" (not "--Users-ooxx-src-dotai--"). - const home = expandHome("~"); - const homeRelative = path.relative(home, normalized); - const outsideHome = homeRelative === ".." || homeRelative.startsWith(`..${path.sep}`); - if (homeRelative && !outsideHome && !path.isAbsolute(homeRelative)) { - const homeBody = homeRelative.replace(/[/\\:]/g, "-"); + const homeBody = homeRelativeSlugBody(workspace); + if (homeBody) { result.homeExact = `-${homeBody}`; + // Keep the trailing separator: without it a sibling workspace whose name + // merely starts with this one (~/src/dotai vs ~/src/dotai-next) would + // qualify as a subdirectory session directory. result.homePrefix = `-${homeBody}-`; } return result; @@ -242,6 +263,16 @@ function transcriptEvents(raw, sourceRef, options) { return events; } +// OMP `/fork` copies every parent entry into the new session file. The header +// timestamp is the fork point, so records stamped before it were produced by the +// parent session. Discovery and reading share this predicate so the session time +// range can never disagree with the events the session actually yields. +function isInheritedForkEntry(raw, forkCutoff) { + if (forkCutoff === null || raw?.type === "session") return false; + const millis = timestampMillis(inferTimestamp(raw)); + return millis !== null && millis < forkCutoff; +} + async function probeTranscript(filePath, scope) { const summary = { sessionId: sessionIdFromFileName(filePath), @@ -250,10 +281,16 @@ async function probeTranscript(filePath, scope) { workspaceMatch: false, validHeader: false, cwd: null, - forkTimestamp: null, parentSessionId: null, + forkTimestamp: null, + // Time range excluding entries inherited from the parent session. Used only + // when the parent is discovered too, so inherited entries stay counted once. + ownedFirstSeen: null, + ownedLastSeen: null, }; + const ownedRange = { firstSeen: null, lastSeen: null }; let headerSeen = false; + let forkCutoff = null; await forEachJsonLine(filePath, (raw) => { if (!headerSeen) { // OMP emits a "title" record before the session header; skip it. @@ -269,12 +306,10 @@ async function probeTranscript(filePath, scope) { summary.workspaceMatch = true; summary.sessionId = raw.id; summary.cwd = raw.cwd; - // OMP /fork creates a new session file with all parent entries copied. - // The parentSession field identifies the source; the header timestamp - // marks the fork point — messages before it are inherited, not new. if (typeof raw.parentSession === "string" && raw.parentSession.length > 0) { summary.parentSessionId = raw.parentSession; - summary.forkTimestamp = raw.timestamp ?? null; + summary.forkTimestamp = normalizeTimestamp(raw.timestamp) ?? null; + forkCutoff = timestampMillis(summary.forkTimestamp); } } else if (raw?.type === "session") { // Multiple headers are not a valid Pi session and can splice content @@ -283,8 +318,12 @@ async function probeTranscript(filePath, scope) { summary.workspaceMatch = false; return false; } - mergeTimeRange(summary, inferTimestamp(raw)); + const timestamp = inferTimestamp(raw); + mergeTimeRange(summary, timestamp); + if (!isInheritedForkEntry(raw, forkCutoff)) mergeTimeRange(ownedRange, timestamp); }); + summary.ownedFirstSeen = ownedRange.firstSeen; + summary.ownedLastSeen = ownedRange.lastSeen; return summary; } @@ -298,13 +337,13 @@ function addRef(sessions, sessionId, workspace, ref) { sourceKinds: new Set(), sourceRefs: [], workspaceCwds: new Set(), - forkTimestamp: null, parentSessionId: null, + forkTimestamp: null, }; - if (ref.forkTimestamp && !session.forkTimestamp) { - session.forkTimestamp = ref.forkTimestamp; - session.parentSessionId = ref.parentSessionId ?? null; - } + // Provenance is per file; the effective fork cutoff is applied per source ref + // so a second transcript for the same id never inherits a foreign cutoff. + session.parentSessionId ??= ref.parentSessionId ?? null; + session.forkTimestamp ??= ref.forkTimestamp ?? null; if (typeof ref.cwd === "string" && ref.cwd.length > 0) session.workspaceCwds.add(ref.cwd); session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); @@ -321,6 +360,12 @@ function finalizeSession(session) { ); } +function matchesSessionDirVariants(name, variants) { + if (name === variants.exact || name.startsWith(variants.prefix)) return true; + if (variants.homeExact == null) return false; + return name === variants.homeExact || name.startsWith(variants.homePrefix); +} + async function listSessionDirectories(sessionsRoot, variants) { let entries; try { @@ -331,10 +376,7 @@ async function listSessionDirectories(sessionsRoot, variants) { return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - .filter((name) => - name === variants.exact || name.startsWith(variants.prefix) || - (variants.homeExact != null && (name === variants.homeExact || name.startsWith(variants.homePrefix))) - ) + .filter((name) => matchesSessionDirVariants(name, variants)) .map((name) => path.join(sessionsRoot, name)); } @@ -442,11 +484,11 @@ export class PiSessionAnalyzer extends SessionAnalyzer { } 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(); + const probes = []; for (const sessionsRoot of transcriptRoot.paths ?? []) { if (!await pathExists(sessionsRoot)) continue; // Custom session directories contain JSONL files directly; the default @@ -467,19 +509,30 @@ export class PiSessionAnalyzer extends SessionAnalyzer { // shared custom directory never leaks foreign-workspace sessions. const probe = await probeTranscript(filePath, scope); 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, - forkTimestamp: probe.forkTimestamp, - parentSessionId: probe.parentSessionId, - lastSeen: probe.lastSeen, - cwd: probe.cwd, - }); + probes.push({ filePath, probe }); } } } + // A fork cutoff only deduplicates evidence the parent session also carries. + // When the parent is absent from this result the inherited entries have no + // other owner, so they stay with the fork rather than vanishing untracked. + const discoveredIds = new Set(probes.map(({ probe }) => probe.sessionId)); + const sessions = new Map(); + for (const { filePath, probe } of probes) { + const dedupeFork = probe.forkTimestamp !== null + && probe.parentSessionId !== null + && discoveredIds.has(probe.parentSessionId); + addRef(sessions, probe.sessionId, scope.workspace, { + kind: transcriptRoot.kind, + role: transcriptRoot.role, + path: filePath, + firstSeen: dedupeFork ? probe.ownedFirstSeen ?? probe.firstSeen : probe.firstSeen, + lastSeen: dedupeFork ? probe.ownedLastSeen ?? probe.lastSeen : probe.lastSeen, + cwd: probe.cwd, + parentSessionId: probe.parentSessionId, + forkTimestamp: dedupeFork ? probe.forkTimestamp : null, + }); + } return [...sessions.values()].map(finalizeSession) .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); } @@ -501,22 +554,18 @@ export class PiSessionAnalyzer extends SessionAnalyzer { const identityCwd = scope._workspaceMatchScope ? sessionWorkspaceCwd(session, scope._workspaceMatchScope) : null; - // OMP forked sessions copy all parent entries. Skip inherited messages - // (timestamp before fork point) to avoid double-counting with the parent. - const forkCutoff = session.forkTimestamp ? timestampMillis(session.forkTimestamp) : null; for (const ref of session.sourceRefs ?? []) { if (remainingLines !== null && remainingLines <= 0) { truncated = true; break; } if (!ref.path.endsWith(".jsonl")) continue; + // Per source ref: discovery decided whether this file's inherited entries + // are already owned by a discovered parent session. + const forkCutoff = ref.forkTimestamp ? timestampMillis(ref.forkTimestamp) : null; const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (raw?.type === "session" && raw?.cwd && !isScopedWorkspaceMatch(raw.cwd, scope)) return; - // Skip entries inherited from parent session in forked files. - if (forkCutoff !== null && raw?.type !== "session") { - const evtTs = timestampMillis(inferTimestamp(raw)); - if (evtTs !== null && evtTs < forkCutoff) return; - } + if (isInheritedForkEntry(raw, forkCutoff)) return; for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } diff --git a/test/sessions/session-analysis-providers.test.mjs b/test/sessions/session-analysis-providers.test.mjs index c7f68549..b550aa73 100644 --- a/test/sessions/session-analysis-providers.test.mjs +++ b/test/sessions/session-analysis-providers.test.mjs @@ -114,7 +114,10 @@ test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", 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")); + // A Windows workspace string keeps its drive letter on every host instead of + // being resolved against the running host's cwd. + assert.equal(workspaceToPiSessionDirVariants("C:\\workspace\\project").exact, "--C--workspace-project--"); + assert.equal(workspaceToPiSessionDirVariants("C:\\workspace\\project").prefix, "--C--workspace-project-"); assert.ok(workspaceToWorkbuddySlugVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); assert.equal( workspaceToGrokSessionDirName("C:\\workspace\\project"), @@ -1487,6 +1490,158 @@ test("Pi provider discovers subdirectory session dirs that share the workspace p assert.equal(result.sources[0].path, path.join(home, "sessions", dirName)); }); +// OMP (Oh My Pi) keys session directories on the home-relative workspace path +// instead of pi's absolute ---- form. os.homedir() reads HOME on POSIX and +// USERPROFILE on Windows, so both are redirected to keep the fixture host-neutral. +async function withFakeHome(prefix, run) { + const fakeHome = await fixtureRoot(prefix); + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + try { + return await run(fakeHome); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +test("Pi derives OMP home-relative session directory names with a bounded prefix", async () => { + await withFakeHome("session-omp-slug-", (fakeHome) => { + const variants = workspaceToPiSessionDirVariants(path.join(fakeHome, "src", "dotai")); + assert.equal(variants.homeExact, "-src-dotai"); + // The trailing separator keeps a sibling workspace out of the prefix match. + assert.equal(variants.homePrefix, "-src-dotai-"); + // A workspace outside the home directory has no home-relative form. + assert.equal(workspaceToPiSessionDirVariants(path.join(fakeHome, "..", "elsewhere")).homeExact, undefined); + assert.equal(workspaceToPiSessionDirVariants(path.dirname(fakeHome)).homeExact, undefined); + // A directory whose name merely begins with ".." is still inside the home + // directory, so it keeps a home-relative form. + assert.equal(workspaceToPiSessionDirVariants(path.join(fakeHome, "..cache")).homeExact, "-..cache"); + }); +}); + +test("Pi discovers OMP home-relative session directories and skips the title preamble", async () => { + await withFakeHome("session-omp-discovery-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const home = path.join(fakeHome, ".omp", "agent"); + const sessionId = "99999999-9999-4999-8999-999999999999"; + await writeJsonl(path.join(home, "sessions", "-src-dotai", `2026-09-01T01-00-00-000Z_${sessionId}.jsonl`), [ + { type: "title", title: "Add the OMP adapter" }, + { type: "session", version: 3, id: sessionId, timestamp: "2026-09-01T01:00:00.000Z", cwd: workspace }, + { + type: "message", + id: "omp1", + parentId: null, + timestamp: "2026-09-01T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "omp prompt" }] }, + }, + ]); + + const analyzer = new PiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.deepEqual(discovery.sessions.map((session) => session.sessionId), [sessionId]); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { includeUserText: true }); + assert.deepEqual(events.filter((event) => event.type === "user").map((event) => event.userText), ["omp prompt"]); + }); +}); + +test("Pi keeps a sibling OMP workspace out of the home-relative prefix match", async () => { + await withFakeHome("session-omp-sibling-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const sibling = path.join(fakeHome, "src", "dotaix"); + const home = path.join(fakeHome, ".omp", "agent"); + // Only the sibling workspace has sessions; the target must stay empty. + await writeJsonl(path.join(home, "sessions", "-src-dotaix", "2026-09-01T01-00-00-000Z_sibling.jsonl"), [ + { type: "session", version: 3, id: "sibling", timestamp: "2026-09-01T01:00:00.000Z", cwd: sibling }, + ]); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 0); + assert.equal(result.sources[0].exists, false); + }); +}); + +test("Pi discovers OMP subdirectory session dirs under the home-relative prefix", async () => { + await withFakeHome("session-omp-subdir-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const subdir = path.join(workspace, "packages", "app"); + const home = path.join(fakeHome, ".omp", "agent"); + await writeJsonl(path.join(home, "sessions", "-src-dotai-packages-app", "2026-09-01T01-00-00-000Z_child.jsonl"), [ + { type: "session", version: 3, id: "omp-child", timestamp: "2026-09-01T01:00:00.000Z", cwd: subdir }, + ]); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.deepEqual(result.sessions.map((session) => session.sessionId), ["omp-child"]); + }); +}); + +const OMP_FORK_PARENT = [ + { type: "session", version: 3, id: "omp-parent", timestamp: "2026-09-01T01:00:00.000Z" }, + { + type: "message", + id: "f1", + parentId: null, + timestamp: "2026-09-01T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "inherited prompt" }] }, + }, +]; +const OMP_FORK_CHILD = [ + { type: "session", version: 3, id: "omp-fork", parentSession: "omp-parent", timestamp: "2026-09-01T02:00:00.000Z" }, + OMP_FORK_PARENT[1], + { + type: "message", + id: "f2", + parentId: "f1", + timestamp: "2026-09-01T02:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "forked prompt" }] }, + }, +]; + +async function readOmpForkSessions(prefix, { includeParent }) { + return withFakeHome(prefix, async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const home = path.join(fakeHome, ".omp", "agent"); + const sessionsDir = path.join(home, "sessions", "-src-dotai"); + const withCwd = (rows) => rows.map((row) => (row.type === "session" ? { ...row, cwd: workspace } : row)); + if (includeParent) { + await writeJsonl(path.join(sessionsDir, "2026-09-01T01-00-00-000Z_omp-parent.jsonl"), withCwd(OMP_FORK_PARENT)); + } + await writeJsonl(path.join(sessionsDir, "2026-09-01T02-00-00-000Z_omp-fork.jsonl"), withCwd(OMP_FORK_CHILD)); + + const analyzer = new PiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + const scope = await analyzer.resolveScope({ workspace, home }); + const byId = new Map(discovery.sessions.map((session) => [session.sessionId, session])); + const prompts = async (sessionId) => (await analyzer.readSession(byId.get(sessionId), scope, { includeUserText: true })) + .filter((event) => event.type === "user") + .map((event) => event.userText); + return { byId, prompts }; + }); +} + +test("Pi counts OMP inherited fork entries once when the parent session is discovered", async () => { + const { byId, prompts } = await readOmpForkSessions("session-omp-fork-", { includeParent: true }); + assert.deepEqual([...byId.keys()].sort(), ["omp-fork", "omp-parent"]); + assert.deepEqual(await prompts("omp-parent"), ["inherited prompt"]); + assert.deepEqual(await prompts("omp-fork"), ["forked prompt"]); + // The fork's reported range starts at the fork point, matching its events. + assert.equal(byId.get("omp-fork").firstSeen, "2026-09-01T02:00:00.000Z"); + assert.equal(byId.get("omp-fork").parentSessionId, "omp-parent"); +}); + +test("Pi retains OMP inherited fork entries when the parent session is absent", async () => { + const { byId, prompts } = await readOmpForkSessions("session-omp-orphan-fork-", { includeParent: false }); + assert.deepEqual([...byId.keys()], ["omp-fork"]); + // Nothing else owns the inherited entries, so dropping them would lose evidence. + assert.deepEqual(await prompts("omp-fork"), ["inherited prompt", "forked prompt"]); + assert.equal(byId.get("omp-fork").firstSeen, "2026-09-01T01:00:10.000Z"); + assert.equal(byId.get("omp-fork").parentSessionId, "omp-parent"); +}); + 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");