diff --git a/.gitignore b/.gitignore index 090be83..f22397f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .DS_Store *.log package-lock.json +graft/.cache/ diff --git a/README.md b/README.md index 799a091..e6ca82e 100644 --- a/README.md +++ b/README.md @@ -217,15 +217,15 @@ It cannot be combined with backpass reads the local transcript stores of seven harnesses directly. No API, no upload. -| Harness | Store | Repo tie | -| -------------- | ---------------------------------------------- | --------------------------------------------------- | -| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | -| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | -| **pi** | standalone and BB-managed Pi JSONL stores | session-header `cwd` | -| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | -| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | -| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | -| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | +| Harness | Store | Repo tie | +| -------------- | ----------------------------------------------- | --------------------------------------------------- | +| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | +| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | +| **pi** | standalone, OMP, and BB-managed Pi JSONL stores | session-header `cwd` | +| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | +| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | +| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | +| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | Claude collection covers `$CLAUDE_CONFIG_DIR/projects` alongside the default store, so a relocated config dir does not hide its sessions. The variable is read from backpass's own @@ -238,6 +238,25 @@ sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR` set in backpass's environment. When roots overlap, backpass scans every applicable layout and reads each JSONL file once. +OMP (Oh My Pi) collection is **off by default**. Set `discovery.includeOmp` to `true` in +`.backpassrc.json` or your [personal config](#configuration) to also read +`~/.omp/agent/sessions/` through the Pi adapter: + +```json +{ "discovery": { "includeOmp": true } } +``` + +Then run `backpass scan --harness pi` (or a normal `backpass` run). The setting also +applies to configured SSH hosts. For `--scope user`, set it in `user.discovery` and +include `"pi"` in `user.discovery.harnesses`; user scope otherwise collects only Claude +and Codex. Explicit Pi store environment overrides still work without this setting; +it controls only the additional default OMP store, not the harness backpass invokes. + +OMP nests subagent JSONL files below each parent session, and a subagent's own subagents +one level further down. Backpass analyzes each file separately, but uses the root session as +their shared corroboration source; a session and its subagents cannot count as independent +sessions. + OpenCode collection reads both store layouts: OpenCode 1.x (`session`, `message`, `part`) and OpenCode 2.x (`session_v2`, `session_message`). For 2.x, session activity uses the later of the session's update time and its newest message's update time. An upgraded store keeps its 1.x tables beside the copies in `session_v2`, so a session found in both is read from `session_v2`. @@ -289,12 +308,12 @@ OpenCode sessions with no recorded messages, such as unused agent probes, are no Every remaining session is labelled **interactive** or **non-interactive** (`src/interaction.js`). Codex `codex exec` / `originator: codex_exec`, Claude SDK, GitHub, action, and CI -entrypoints, OpenCode child sessions (`parent_id`), and a cwd with a `.no-mistakes` path -segment are non-interactive. Hermes gateway, cron, and WhatsApp sessions are classified the -same way if they leak past collection's source filter. A no-mistakes pipeline run is just one -kind of non-interactive session, not its own category. Missing harness metadata defaults to -interactive. `backpass scan`, the proposal, and apply all print the mix so relevance is never -silently computed against a robot-skewed pool. +entrypoints, OpenCode child sessions (`parent_id`), OMP subagent transcripts, and a cwd +with a `.no-mistakes` path segment are non-interactive. Hermes gateway, cron, and WhatsApp +sessions are classified the same way if they leak past collection's source filter. A +no-mistakes pipeline run is just one kind of non-interactive session, not its own category. +Missing harness metadata defaults to interactive. `backpass scan`, the proposal, and apply +all print the mix so relevance is never silently computed against a robot-skewed pool. ```sh backpass scan --since 7d --strict @@ -812,6 +831,7 @@ CLI flags on top: "since": "30d", "worktreeGlobs": [], "cloneRoots": [], + "includeOmp": false, "minUserTurns": 2 }, "jobs": 4 diff --git a/src/acpx.js b/src/acpx.js index 5019733..9102e1f 100644 --- a/src/acpx.js +++ b/src/acpx.js @@ -906,8 +906,9 @@ function recoverPiUsage({ promptFile, cwd, startedAt }) { .filter((c) => c.mtimeMs >= since) .sort((a, b) => b.mtimeMs - a.mtimeMs); + const scanContext = piStore.createScanContext(); for (const candidate of candidates) { - const descriptor = piStore.classify(candidate); + const descriptor = piStore.classify(candidate, { scanContext }); if (!descriptor || !wanted.has(descriptor.cwd)) continue; const entries = readJsonl(candidate.path); const firstUser = entries.find((e) => e.type === "message" && e.message?.role === "user"); diff --git a/src/analyze.js b/src/analyze.js index a45be6f..7bbcf78 100644 --- a/src/analyze.js +++ b/src/analyze.js @@ -13,7 +13,7 @@ import { renderOpenGapIndex } from "./gap-ledger.js"; import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js"; import { emitProgress } from "./progress.js"; import { UserError, color, info, warn } from "./logger.js"; -import { transcriptIdentity } from "./transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "./transcript.js"; /** * Stage 1 of the pipeline (design section 3): one cheap model call per transcript, @@ -410,6 +410,10 @@ export async function analyzeTranscripts({ harness: transcript.harness, id: transcript.id, identity: transcriptIdentity(transcript), + parentSessionId: transcript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(transcript), + corroborationNativeId: transcript.corroborationNativeId || null, + corroborationStartedAt: transcript.corroborationStartedAt ?? null, path: transcript.path, mtimeMs: transcript.mtimeMs, bytes: transcript.bytes, diff --git a/src/commands/propose.js b/src/commands/propose.js index 9d59b56..04522c3 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -1,6 +1,11 @@ import { consolidateGapLedger } from "../consolidate.js"; import { foldEvidence } from "../fold.js"; -import { ledgerGapObservations, pruneGapLedger, recordGapObservations } from "../gap-ledger.js"; +import { + ledgerGapObservations, + normalizeGapLedgerSessions, + pruneGapLedger, + recordGapObservations, +} from "../gap-ledger.js"; import { synthesizeProposal } from "../synthesize.js"; import { ProposalViolation } from "../proposal.js"; import { formatCorpusMix, INTERACTIVE, NON_INTERACTIVE } from "../interaction.js"; @@ -13,7 +18,7 @@ import { printUsage } from "./usage.js"; import { closeRemoteDiscovery, discoverForRun } from "./scan.js"; import { capTranscripts } from "../sample.js"; import { isEvidenceFresh } from "../state.js"; -import { transcriptIdentity } from "../transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "../transcript.js"; import { pruneHostCache } from "../discovery/cache.js"; /** @@ -29,11 +34,12 @@ import { pruneHostCache } from "../discovery/cache.js"; * cap remain on disk. Folding those records would inflate `analyzedSessions` beyond the * sampled corpus or score positional instruction aliases against an index they never saw. * Legacy records stay excluded until ordinary discovery and analysis backfill them. + * Current discovery's corroboration fields are overlaid on admitted records, so a + * subagent analyzed before its parent appeared still folds under the parent's identity. */ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], transcripts = [], { route = null } = {}) { const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config; const selectedByIdentity = new Map(transcripts.map((transcript) => [transcriptIdentity(transcript), transcript])); - const selected = new Set(selectedByIdentity.keys()); const evidence = state.listEvidence(); const identitiesByLegacyId = new Map(); for (const record of evidence) { @@ -42,26 +48,41 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans if (!identitiesByLegacyId.has(legacyId)) identitiesByLegacyId.set(legacyId, new Set()); identitiesByLegacyId.get(legacyId).add(transcriptIdentity(record.transcript)); } - const selectedGapSessions = new Set(selected); + const selectedGapSessions = new Set(selectedByIdentity.keys()); + const legacyIds = new Set(); for (const transcript of transcripts) { + selectedGapSessions.add(corroborationIdentityOf(transcript)); const identities = identitiesByLegacyId.get(transcript.id); if (identities?.size === 1 && identities.has(transcriptIdentity(transcript))) { selectedGapSessions.add(transcript.id); + legacyIds.add(transcript.id); } } - const relevant = evidence.filter((e) => { - const currentTranscript = selectedByIdentity.get(transcriptIdentity(e.transcript)); - return ( - e.memoryPath === memoryFile.path && - e.memoryHash === memoryHash && - (e.transcript?.interaction === INTERACTIVE || e.transcript?.interaction === NON_INTERACTIVE) && - currentTranscript && - isEvidenceFresh(e, currentTranscript, memoryHash) - ); - }); + const relevant = []; + for (const record of evidence) { + const currentTranscript = selectedByIdentity.get(transcriptIdentity(record.transcript)); + if ( + record.memoryPath !== memoryFile.path || + record.memoryHash !== memoryHash || + (record.transcript?.interaction !== INTERACTIVE && record.transcript?.interaction !== NON_INTERACTIVE) || + !currentTranscript || + !isEvidenceFresh(record, currentTranscript, memoryHash) + ) { + continue; + } + const transcript = { + ...record.transcript, + parentSessionId: currentTranscript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(currentTranscript), + corroborationNativeId: currentTranscript.corroborationNativeId || null, + corroborationStartedAt: currentTranscript.corroborationStartedAt ?? null, + }; + relevant.push({ ...record, transcript }); + } const ledger = state.readGapLedger(); - recordGapObservations(ledger, relevant, { skills }); + normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); + recordGapObservations(ledger, relevant, { skills, legacyIds }); // Consolidate after recording, so the pass sees this run's sightings too: two // sessions coining the same brand-new gap in one parallel fan-out can only line up // here. One bounded judged call; a failure degrades to lexical identity and the run diff --git a/src/config.js b/src/config.js index 316997a..28af46b 100644 --- a/src/config.js +++ b/src/config.js @@ -119,6 +119,8 @@ export const DEFAULT_CONFIG = { * is refused by name. Absolute paths; `~` is expanded. */ opencodeStores: [], + /** Include Oh My Pi's default session store in Pi discovery; opt-in only. */ + includeOmp: false, minUserTurns: 2, includeCursorIde: false, }, @@ -467,6 +469,9 @@ function validate(config, { kind = "project", repoRoot = null } = {}) { } config.discovery.harnesses = config.discovery.harnesses.filter((h) => known.has(h)); parseSince(config.discovery.since); + if (typeof config.discovery.includeOmp !== "boolean") { + throw new UserError("config.discovery.includeOmp must be a boolean"); + } if (!Array.isArray(config.discovery.cloneRoots) || config.discovery.cloneRoots.some((p) => typeof p !== "string")) { throw new UserError("config.discovery.cloneRoots must be an array of paths"); } diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 82e5e1f..8978c5c 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -16,15 +16,26 @@ import { /** * Pi writes standalone sessions under - * `~/.pi/agent/sessions//_.jsonl`. BB's Pi bridge writes the - * same JSONL shape directly under `/pi-bridge-sessions/`. + * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the + * same JSONL shape under `~/.omp/agent/sessions/` (opt-in via `discovery.includeOmp`) + * and honors `PI_CODING_AGENT_DIR`, but + * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` + * entry is line 2 there. omp also writes subagent transcripts one level deeper, at + * `//.jsonl`, and their own subagents at + * `///..jsonl`; every descendant is related to + * the root session. BB's Pi bridge writes the same JSONL shape directly under + * `/pi-bridge-sessions/`. * - * Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in - * order, so a linear read is faithful. `model_change` / `thinking_level_change` records - * give the model actually used. No remote is recorded - dead worktrees reach tier 3 only. + * Entries form a parent/child tree but arrive in order, so a linear read is faithful. + * `model_change` / `thinking_level_change` records give the model actually used + * (`modelId` on pi, `model` on omp). No remote is recorded - dead worktrees reach tier 3 + * only. */ export const name = "pi"; +export const cacheVersion = 4; + +const SUBAGENT_DEPTH = 2; export function storeRoot() { return home(".pi", "agent", "sessions"); @@ -46,11 +57,14 @@ function realpathOrResolve(value) { } } -function storeSpecs() { +function storeSpecs(config) { const specs = [ { path: storeRoot(), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; + if (config?.discovery?.includeOmp === true) { + specs.push({ path: home(".omp", "agent", "sessions"), direct: false, nested: true }); + } const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); if (piAgentDir) specs.push({ path: path.join(piAgentDir, "sessions"), direct: false, nested: true }); const piSessionDir = expandEnvPath(process.env.PI_CODING_AGENT_SESSION_DIR); @@ -74,17 +88,19 @@ function storeSpecs() { return [...unique.values()]; } -export function storeRoots() { - return storeSpecs().map((spec) => spec.path); +/** @param {{ discovery?: { includeOmp?: boolean } }} [config] */ +export function storeRoots(config) { + return storeSpecs(config).map((spec) => spec.path); } -export function enumerate() { +/** @param {{ config?: { discovery?: { includeOmp?: boolean } }, cutoffMs?: number | null, repo?: object }} [options] */ +export function enumerate({ config } = {}) { const out = []; const seen = new Set(); - for (const spec of storeSpecs()) { + for (const spec of storeSpecs(config)) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested ? listDirs(spec.path).flatMap((dir) => listFiles(dir, ".jsonl")) : []), + ...(spec.nested ? listDirs(spec.path).flatMap((dir) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -98,11 +114,80 @@ export function enumerate() { return out; } -export function classify(candidate) { - const [first] = readHeadLines(candidate.path, 1); - const entry = first && parseJsonLine(first); +function sessionFiles(dir, depth) { + return [ + ...listFiles(dir, ".jsonl"), + ...(depth > 0 ? listDirs(dir).flatMap((sub) => sessionFiles(sub, depth - 1)) : []), + ]; +} + +export function createScanContext() { + return { parentHeaders: new Map() }; +} + +function readSessionHeader(file) { + const [firstLine, secondLine] = readHeadLines(file, 2); + const first = parseJsonLine(firstLine); + return first?.type === "session" ? first : first?.type === "title" ? parseJsonLine(secondLine) : null; +} + +function readParentSession(parentPath, scanContext) { + const cache = scanContext?.parentHeaders; + if (cache?.has(parentPath)) return cache.get(parentPath); + const stat = statOrNull(parentPath); + const entry = stat?.isFile() ? readSessionHeader(parentPath) : null; + const result = + entry?.type === "session" + ? { + entry, + stat, + fingerprint: JSON.stringify([ + stat.dev, + stat.ino, + stat.mtimeMs, + stat.ctimeMs, + stat.size, + entry.id ?? null, + entry.cwd ?? null, + entry.timestamp ?? null, + ]), + } + : null; + cache?.set(parentPath, result); + return result; +} + +function parentSessionPathFor(candidatePath) { + const sessionDir = path.dirname(candidatePath); + return path.join(path.dirname(sessionDir), `${path.basename(sessionDir)}.jsonl`); +} + +function ancestorSessionPaths(candidatePath) { + const out = []; + let current = candidatePath; + for (let depth = 0; depth < SUBAGENT_DEPTH; depth += 1) { + current = parentSessionPathFor(current); + out.push(current); + } + return out; +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function cacheDependency(candidate, options = {}) { + return JSON.stringify( + ancestorSessionPaths(candidate.path).map( + (ancestorPath) => readParentSession(ancestorPath, options.scanContext)?.fingerprint ?? null, + ), + ); +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function classify(candidate, options = {}) { + const { scanContext } = options; + const entry = readSessionHeader(candidate.path); if (!entry || entry.type !== "session" || !entry.cwd) return null; - return { + + const descriptor = { id: entry.id || path.basename(candidate.path, ".jsonl"), cwd: entry.cwd, gitBranch: null, @@ -111,6 +196,17 @@ export function classify(candidate) { model: null, interactionSignals: emptyInteractionSignals(), }; + + for (const ancestorPath of ancestorSessionPaths(candidate.path)) { + const ancestorInfo = readParentSession(ancestorPath, scanContext); + const ancestor = ancestorInfo?.entry; + if (!ancestor) continue; + descriptor.parentSessionId = ancestor.id || path.basename(ancestorPath, ".jsonl"); + descriptor.parentSessionPath = ancestorPath; + descriptor.parentSessionStartedAt = ancestor.timestamp ? Date.parse(ancestor.timestamp) : ancestorInfo.stat.mtimeMs; + } + + return descriptor; } export function read(ref) { @@ -120,7 +216,7 @@ export function read(ref) { for (const entry of entries) { if (entry.type === "model_change") { - model = entry.modelId || model; + model = entry.modelId || entry.model || model; continue; } if (entry.type !== "message" || !entry.message) continue; diff --git a/src/discovery/hosts.js b/src/discovery/hosts.js index 7322a52..8fd5378 100644 --- a/src/discovery/hosts.js +++ b/src/discovery/hosts.js @@ -216,10 +216,10 @@ async function locate(entry, controlPath) { /** * Discover on every configured host, fail-soft per host. * - * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string }} options + * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string, includeOmp?: boolean }} options * @returns {Promise} one result per host, in configured order */ -export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) { +export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath, includeOmp = false }) { const results = []; for (const entry of hosts) { const result = emptyHostResult(entry); @@ -243,7 +243,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) result.error = `failed to start ssh control master: ${masterFailure.message}`; } else { result.master = masterCall.master; - await collectOneHost(entry, { harnesses, cutoffMs }, result); + await collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result); } } catch (err) { if (err instanceof UserError) { @@ -272,7 +272,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) return results; } -async function collectOneHost(entry, { harnesses, cutoffMs }, result) { +async function collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result) { const located = await locate(entry, result.master.controlPath); if (located.failure) { result.error = located.failure.message; @@ -318,7 +318,7 @@ async function collectOneHost(entry, { harnesses, cutoffMs }, result) { } const program = buildProbeProgram( - { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs }, + { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs, includeOmp }, { env: entry.env, }, diff --git a/src/discovery/index.js b/src/discovery/index.js index 87b6269..b078e9c 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -39,9 +39,12 @@ export function getAdapter(harness) { * Discovery (design section 2). * * For file-backed stores the expensive step is reading each transcript's header, so - * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. - * Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on - * the machine this was designed against. + * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. An + * adapter may also export `cacheVersion` (bumped when its classification semantics + * change) and `cacheDependency` (a fingerprint of other files a descriptor reads, such + * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Re-scans are + * then O(new files) - which matters: codex alone had 10,317 rollouts on the machine this + * was designed against. * * SQLite-backed stores (opencode, hermes, cursor IDE) query session metadata directly, * so they skip the file-header cache entirely. @@ -151,6 +154,7 @@ export async function discoverTranscripts({ hosts, harnesses: selected.filter((h) => getAdapter(h)), cutoffMs, + includeOmp: config.discovery.includeOmp, controlPath: createControlPath(), }); remoteMasters.push(...collected.map((result) => result.master).filter(Boolean)); @@ -316,8 +320,10 @@ function discoverFiles( { repo, config, cutoffMs, strict, stats, cache, markDirty, associateFn, stateDir, userFilter }, ) { const candidates = adapter.enumerate({ cutoffMs, repo, config }); + const scanContext = adapter.createScanContext?.(); const out = []; + const hasCacheDependency = typeof adapter.cacheDependency === "function"; for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; stats.scanned += 1; @@ -334,19 +340,31 @@ function discoverFiles( const cacheKey = `${adapter.name}:${candidate.key}`; const cached = cache.entries[cacheKey]; + const cacheDependency = hasCacheDependency + ? adapter.cacheDependency(candidate, { repo, config, scanContext }) + : undefined; let descriptor; if ( cached && + cached.cacheVersion === adapter.cacheVersion && cached.mtimeMs === candidate.mtimeMs && cached.bytes === candidate.bytes && + (!hasCacheDependency || cached.cacheDependency === cacheDependency) && hasInteractionSignals(cached.descriptor) ) { stats.cached += 1; descriptor = cached.descriptor; } else { - descriptor = adapter.classify(candidate, { repo, config }) || null; - cache.entries[cacheKey] = { mtimeMs: candidate.mtimeMs, bytes: candidate.bytes, descriptor }; + descriptor = adapter.classify(candidate, { repo, config, scanContext }) || null; + const cacheEntry = { + cacheVersion: adapter.cacheVersion, + mtimeMs: candidate.mtimeMs, + bytes: candidate.bytes, + descriptor, + }; + if (hasCacheDependency) cacheEntry.cacheDependency = cacheDependency; + cache.entries[cacheKey] = cacheEntry; markDirty(); } @@ -408,6 +426,21 @@ function toTranscript(adapter, row, association, id, { host = null, remote = nul remote, }; transcript.identity = transcriptIdentity(transcript); + if (row.parentSessionId && row.parentSessionPath) { + transcript.parentSessionId = row.parentSessionId; + transcript.corroborationIdentity = transcriptIdentity({ + ...transcript, + identity: null, + nativeId: row.parentSessionId, + path: row.parentSessionPath, + }); + transcript.corroborationNativeId = row.parentSessionId; + transcript.corroborationStartedAt = row.parentSessionStartedAt ?? transcript.startedAt; + } else { + transcript.corroborationIdentity = transcript.identity; + transcript.corroborationNativeId = id; + transcript.corroborationStartedAt = transcript.startedAt; + } transcript.interaction = classifyInteraction(transcript); return transcript; } diff --git a/src/discovery/remote/probe.js b/src/discovery/remote/probe.js index 1d55f98..6aa41c5 100644 --- a/src/discovery/remote/probe.js +++ b/src/discovery/remote/probe.js @@ -89,6 +89,9 @@ async function descriptorFrom(adapter, row, id) { remotes: Array.isArray(row.remotes) ? row.remotes : [], title: row.title || null, startedAt: row.startedAt || null, + parentSessionId: row.parentSessionId, + parentSessionPath: row.parentSessionPath, + parentSessionStartedAt: row.parentSessionStartedAt, mtimeMs: row.mtimeMs || 0, bytes: row.bytes || 0, contentSignature, @@ -101,7 +104,7 @@ async function descriptorFrom(adapter, row, id) { }; } -async function discoverHarness(adapter, { cutoffMs }) { +async function discoverHarness(adapter, { cutoffMs, includeOmp }) { const stats = { scanned: 0, classified: 0, self: 0, error: null }; const out = []; const warnings = []; @@ -124,10 +127,12 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } - for (const candidate of adapter.enumerate({ cutoffMs })) { + const candidates = adapter.enumerate({ cutoffMs, config: { discovery: { includeOmp } } }); + const scanContext = adapter.createScanContext?.(); + for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; stats.scanned += 1; - const classified = adapter.classify(candidate); + const classified = adapter.classify(candidate, { scanContext }); if (!classified) continue; stats.classified += 1; const merged = { ...candidate, ...classified }; @@ -157,8 +162,8 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } -/** @param {{ harnesses?: string[], cutoffMs?: number | null }} request */ -export async function discover({ harnesses = [], cutoffMs = null } = {}) { +/** @param {{ harnesses?: string[], cutoffMs?: number | null, includeOmp?: boolean }} request */ +export async function discover({ harnesses = [], cutoffMs = null, includeOmp = false } = {}) { const harnessStats = Object.create(null); const descriptors = []; const warnings = []; @@ -176,7 +181,7 @@ export async function discover({ harnesses = [], cutoffMs = null } = {}) { continue; } try { - const result = await discoverHarness(adapter, { cutoffMs }); + const result = await discoverHarness(adapter, { cutoffMs, includeOmp }); harnessStats[harness] = result.stats; descriptors.push(...result.descriptors); warnings.push(...result.warnings); diff --git a/src/fold.js b/src/fold.js index 768c282..2ae0152 100644 --- a/src/fold.js +++ b/src/fold.js @@ -12,6 +12,7 @@ import { normalizeSourceLabel, } from "./gap-ledger.js"; import { crossSurfaceDuplicates } from "./overlap.js"; +import { corroborationIdentityOf } from "./transcript.js"; /** * Stage 2 of the pipeline (design section 3): fold per-transcript evidence into one @@ -107,7 +108,7 @@ export function foldEvidence( const issuedSources = disambiguateSourceLabels([ ...usable.map((record) => ({ source: gapSource(record.transcript), - identity: record.transcript.identity || record.transcript.id, + identity: corroborationIdentityOf(record.transcript), })), ...persistedObservations.map((observation) => ({ source: observation?.source, @@ -117,6 +118,8 @@ export function foldEvidence( const recordSources = issuedSources.slice(0, usable.length); const observationSources = issuedSources.slice(usable.length); for (const [index, record] of usable.entries()) { + const sessionIdentity = record.transcript.identity || record.transcript.id; + const corroborationIdentity = corroborationIdentityOf(record.transcript); if (record.usedRawTranscript) usedRawCount += 1; const source = recordSources[index]; sources.add(source); @@ -126,7 +129,6 @@ export function foldEvidence( for (const item of record[polarity] || []) { const entry = touch(item.instruction); entry[polarity] += 1; - const sessionIdentity = record.transcript.identity || record.transcript.id; const category = classifyInteraction(record.transcript); entry.sessions.add(sessionIdentity); entry.sessionsByInteraction[category].add(sessionIdentity); @@ -138,9 +140,9 @@ export function foldEvidence( // `class` is what a negative means (harm vs non-compliance vs irrelevant); // `harmSessions` is what the removal-evidence floor counts. A record from // before the class existed carries none and never counts as harm. - if (polarity === "negative" && item.class === "harm") entry.harmSessions.add(sessionIdentity); + if (polarity === "negative" && item.class === "harm") entry.harmSessions.add(corroborationIdentity); if (polarity === "negative" && item.class === "non-compliance") { - entry.nonComplianceSessions.add(sessionIdentity); + entry.nonComplianceSessions.add(corroborationIdentity); } entry.quotes.push({ polarity, @@ -162,7 +164,7 @@ export function foldEvidence( quote: gap.quote, recurrenceRisk: gap.recurrenceRisk, source, - sessionId: record.transcript.identity || record.transcript.id, + sessionId: corroborationIdentity, domain: gap.domain === "orchestration" ? "orchestration" : "project", project: record.transcript.project || null, projectRoot: record.transcript.projectRoot || null, diff --git a/src/gap-ledger.js b/src/gap-ledger.js index e405c30..d38ca9f 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -1,6 +1,7 @@ import { parseMemoryUnits, similarity } from "./memory.js"; import { parseSince } from "./config.js"; import { sha256 } from "./state.js"; +import { corroborationIdentityOf } from "./transcript.js"; /** * Durable gap corroboration across runs (`.backpass/gap-ledger.json`). @@ -34,10 +35,14 @@ import { sha256 } from "./state.js"; * afterward (majority orchestration withholds a cluster from proposals; a mixed * cluster stays visible). A missing domain counts as project, so evidence from * before the field existed keeps its old behavior. - * - Sessions are keyed by canonical transcript identity (with the legacy id as a fallback), - * so re-analyzing or re-sampling the same source session overwrites its observation and - * never adds a count. Persisted observations only contribute when that identity belongs - * to the current selected sample, so sessions outside the window or cap cannot skew fold. + * - Sessions are keyed by corroboration identity (`corroborationIdentityOf`: the root + * session's canonical identity for an OMP subagent, the transcript's own otherwise), so + * re-analyzing or re-sampling the same source session, or a subagent of it, overwrites + * its observation and never adds a count. An older per-file identity key migrates to + * it; a legacy id key migrates only when the fold proved that id belongs to one + * evidence identity (`legacyIds`). Persisted observations only contribute when that + * identity belongs to the current selected sample, so sessions outside the window or + * cap cannot skew fold. * - A gap is a fact about its session: re-analysis that no longer mentions it is model * noise, not the session changing, so observations are only ever replaced, not removed * by absence. They retire in exactly two ways: the memory surface gains content @@ -70,9 +75,11 @@ export function emptyGapLedger() { * cross-machine corroboration actually is: two machines hitting one gap, named. */ export function gapSource(transcript = {}) { - const date = transcript.startedAt ? new Date(transcript.startedAt).toISOString().slice(0, 10) : "unknown date"; + const startedAt = transcript.corroborationStartedAt ?? transcript.startedAt; + const date = startedAt ? new Date(startedAt).toISOString().slice(0, 10) : "unknown date"; const host = transcript.host ? ` · ${transcript.host}` : ""; - return `${transcript.harness} · ${sessionSourceId(transcript)} · ${date}${host}`; + const sourceId = transcript.corroborationNativeId || sessionSourceId(transcript); + return `${transcript.harness} · ${sourceId} · ${date}${host}`; } export function sessionSourceId(transcript = {}) { @@ -162,21 +169,41 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } +function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { + return [...new Set([transcript.identity, transcript.id])].filter( + (identity) => identity && identity !== sessionIdentity && (identity !== transcript.id || legacyIds.has(identity)), + ); +} + +function takePriorObservations(entry, sessionIdentity, aliases) { + const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + Boolean, + ); + const firstObservedAt = priors + .map((observation) => observation.firstObservedAt || observation.observedAt) + .filter((value) => Number.isFinite(Date.parse(value))) + .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; + const coveredBySkill = priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + for (const alias of aliases) delete entry.sessions[alias]; + return { priors, firstObservedAt, coveredBySkill }; +} + /** * Fold this run's evidence into the ledger. One observation per (gap, session); a * session seen again replaces its own observation and keeps its first-seen timestamp. + * A legacy `transcript.id` key is that session's only when it is in `legacyIds`. * - * @param {{ now?: Date, skills?: unknown[] }} [options] + * @param {{ now?: Date, skills?: unknown[], legacyIds?: Set }} [options] */ export function recordGapObservations(ledger, evidenceRecords, options = {}) { - const { now = new Date() } = options; + const { now = new Date(), legacyIds = new Set() } = options; const observedAt = new Date(now).toISOString(); let recorded = 0; for (const record of evidenceRecords) { if (!record || record.status !== "ok" || !record.memoryPath) continue; const transcript = record.transcript || {}; - const sessionIdentity = transcript.identity || transcript.id; - if (!sessionIdentity) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + if (!(transcript.corroborationIdentity || transcript.identity || transcript.id)) continue; for (const gap of record.gaps || []) { if (!gap || !gap.proposedInstruction) continue; // A citation from the analysis turn wins over word overlap: the model saw both @@ -206,16 +233,13 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const identityPrior = entry.sessions[sessionIdentity]; - const aliasPrior = transcript.id && transcript.id !== sessionIdentity ? entry.sessions[transcript.id] : null; - const priors = [identityPrior, aliasPrior].filter(Boolean); - const firstObservedAt = priors - .map((observation) => observation.firstObservedAt || observation.observedAt) - .filter((value) => Number.isFinite(Date.parse(value))) - .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; - if (aliasPrior) delete entry.sessions[transcript.id]; - const coveredBySkill = - gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + const prior = takePriorObservations( + entry, + sessionIdentity, + sessionIdentityAliases(transcript, sessionIdentity, legacyIds), + ); + const { priors, firstObservedAt } = prior; + const coveredBySkill = gap.coveredBySkill || prior.coveredBySkill; const phrasings = [ ...new Set([ ...priors.flatMap( @@ -228,14 +252,21 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { firstObservedAt: firstObservedAt || observedAt, observedAt, sessionStartedAt: - transcript.startedAt ?? identityPrior?.sessionStartedAt ?? aliasPrior?.sessionStartedAt ?? null, + transcript.corroborationStartedAt ?? + transcript.startedAt ?? + priors.find((observation) => observation.sessionStartedAt)?.sessionStartedAt ?? + null, memoryHash: record.memoryHash || null, source: gapSource(transcript), mistake: gap.mistake, quote: gap.quote, recurrenceRisk: gap.recurrenceRisk, phrasings, - domain: gap.domain === "orchestration" ? "orchestration" : "project", + domain: + gap.domain === "orchestration" && + !priors.some((observation) => observation.observedAt === observedAt && observation.domain !== "orchestration") + ? "orchestration" + : "project", // A failed trigger: the analysis judged an existing skill's content to cover // this mistake. Absent when no skill covers it (including all pre-existing // observations), and absence never counts as a citation. @@ -248,6 +279,50 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } +/** + * Re-key selected sessions in old ledgers when related transcript files now share an + * identity. A legacy `transcript.id` key migrates only when it is in `legacyIds`: the ids + * the fold proved belong to exactly one evidence identity, so an ambiguous id never moves + * another session's sighting onto a selected one. + */ +export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = new Set() } = {}) { + const selections = []; + for (const transcript of transcripts) { + if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); + if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); + } + + for (const entry of Object.values(ledger.entries)) { + for (const { transcript, sessionIdentity, aliases } of selections) { + if (!aliases.some((identity) => entry.sessions[identity])) continue; + const { priors, firstObservedAt, coveredBySkill } = takePriorObservations(entry, sessionIdentity, aliases); + const current = priors[0]; + const project = current.project || priors.find((observation) => observation.project)?.project; + const projectRoot = current.projectRoot || priors.find((observation) => observation.projectRoot)?.projectRoot; + + entry.sessions[sessionIdentity] = { + ...current, + ...(firstObservedAt ? { firstObservedAt } : {}), + sessionStartedAt: transcript.corroborationStartedAt ?? transcript.startedAt ?? current.sessionStartedAt ?? null, + source: gapSource(transcript), + phrasings: [ + ...new Set([ + entry.proposedInstruction, + ...priors.flatMap( + (observation) => observation.phrasings || [observation.proposedInstruction].filter(Boolean), + ), + ]), + ].filter(Boolean), + domain: priors.some((observation) => observation.domain !== "orchestration") ? "project" : "orchestration", + ...(coveredBySkill ? { coveredBySkill } : {}), + ...(project ? { project } : {}), + ...(projectRoot ? { projectRoot } : {}), + }; + } + } +} /** * Retire observations that no longer count: sightings first seen more than `maxAge` ago diff --git a/src/interaction.js b/src/interaction.js index 020f1fa..3fd28e9 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -6,9 +6,10 @@ * * Non-interactive is detected best-effort from per-harness metadata (codex * `originator: codex_exec` / `source: exec`, claude `entrypoint` values that start - * with `sdk`, an OpenCode child `parent_id`, Hermes cron/gateway/whatsapp if they - * ever leak past discovery) and from cwd (a `.no-mistakes` path segment - pipeline - * worktrees are one kind of non-interactive run, not their own category). + * with `sdk`, an OpenCode child `parent_id`, an OMP subagent's `parentSessionId`, + * Hermes cron/gateway/whatsapp if they ever leak past discovery) and from cwd (a + * `.no-mistakes` path segment - pipeline worktrees are one kind of non-interactive run, + * not their own category). */ export const INTERACTIVE = "interactive"; @@ -57,9 +58,12 @@ function claudeEntrypointIsNonInteractive(entrypoint) { /** * Map a discovered transcript (or adapter descriptor) onto the two public categories. - * Explicit `transcript.interaction` is trusted when it is already one of the two labels. + * A `parentSessionId` (an OMP subagent) is always non-interactive, even over a stamped + * label; otherwise an explicit `transcript.interaction` is trusted when it is already one + * of the two labels. */ export function classifyInteraction(transcript) { + if (transcript?.parentSessionId) return NON_INTERACTIVE; const stamped = transcript?.interaction; if (stamped === INTERACTIVE || stamped === NON_INTERACTIVE) return stamped; diff --git a/src/transcript.js b/src/transcript.js index 32dc519..d39a789 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -37,3 +37,8 @@ export function transcriptIdentity(transcript) { ) .digest("hex"); } + +/** Shared observer identity for related transcript files such as OMP subagents. */ +export function corroborationIdentityOf(transcript) { + return transcript?.corroborationIdentity || transcriptIdentity(transcript); +} diff --git a/test/adapters.test.js b/test/adapters.test.js index d95bacd..a226b63 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -151,6 +151,123 @@ test("pi adapter reads the session header and drops thinking blocks", () => { assert.equal(toolCall.result, "nothing to commit"); }); +test("pi adapter classifies omp sessions past the title record and reads model", () => { + const file = path.join(FIXTURES, "omp-session.jsonl"); + const descriptor = pi.classify(candidateFor(file)); + assert.equal(descriptor.id, "omp-5678"); + assert.equal(descriptor.cwd, "/repo/demo"); + + const { events, model } = pi.read({ path: file }); + assert.equal(model, "cursor/composer-2.5", "omp model_change carries model, not modelId"); + const [toolCall] = tools(events); + assert.equal(toolCall.name, "bash"); + assert.equal(toolCall.result, "nothing to commit"); +}); +test("pi adapter accepts only line one or line two after a title header", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-header-")); + const title = JSON.stringify({ type: "title", v: 1, title: "" }); + const session = JSON.stringify({ type: "session", version: 3, id: "late", cwd: "/repo/demo" }); + const other = JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }); + const afterTitle = path.join(dir, "after-title.jsonl"); + const afterOther = path.join(dir, "after-other.jsonl"); + + fs.writeFileSync(afterTitle, `${title}\n${other}\n${session}\n`); + fs.writeFileSync(afterOther, `${other}\n${session}\n`); + + assert.equal(pi.classify(candidateFor(afterTitle)), null, "line three is outside the header"); + assert.equal(pi.classify(candidateFor(afterOther)), null, "line two is a header only after a title record"); +}); + +test("pi adapter links an OMP subagent to its sibling parent session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + assert.equal(child.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); + assert.equal(pi.classify(candidateFor(parentPath)).parentSessionId, undefined); +}); + +test("pi adapter links a second-level OMP subagent to the root session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/repo/demo" }); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.id, "grandchild-native"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); + assert.equal(grandchild.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); +}); + +test("pi adapter links OMP subagents by nested path even when their cwd differs", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo/packages/api" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/worktrees/demo-isolated" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.cwd, "/repo/demo/packages/api", "association still uses the subagent's own cwd"); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.cwd, "/worktrees/demo-isolated"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); +}); + +test("pi discovery checks a missing parent path once per scan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-parent-cache-")); + const sessionDir = path.join(root, "sessions", "-repo-demo"); + const firstPath = path.join(sessionDir, "first.jsonl"); + const secondPath = path.join(sessionDir, "second.jsonl"); + writePiSession(firstPath, { id: "first", cwd: "/repo/demo" }); + writePiSession(secondPath, { id: "second", cwd: "/repo/demo" }); + const candidates = [candidateFor(firstPath), candidateFor(secondPath)]; + const missingParentPath = path.join(root, "sessions", "-repo-demo.jsonl"); + const scanContext = pi.createScanContext(); + const originalStatSync = fs.statSync; + let parentProbes = 0; + + fs.statSync = function (file, ...args) { + if (file === missingParentPath) parentProbes += 1; + return originalStatSync.call(this, file, ...args); + }; + try { + for (const candidate of candidates) pi.classify(candidate, { scanContext }); + } finally { + fs.statSync = originalStatSync; + } + + assert.ok(parentProbes <= 1, "ordinary sessions in one store should not repeat the same missing-parent lookup"); +}); +function writeOmpSession(file, { id, cwd }) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-27T00:00:00.000Z", pad: " " })}\n` + + `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd })}\n`, + ); +} + function writePiSession(file, { id, cwd }) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -200,6 +317,27 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl id: "standalone", cwd: "/repo/demo", }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone.jsonl"), { + id: "omp-standalone", + cwd: "/repo/demo", + }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone", "Subagent.jsonl"), { + id: "omp-subagent", + cwd: "/repo/demo", + }); + writeOmpSession( + path.join( + fakeHome, + ".omp", + "agent", + "sessions", + "-repo-demo", + "omp-standalone", + "Subagent", + "Subagent.Child.jsonl", + ), + { id: "omp-nested-subagent", cwd: "/repo/demo" }, + ); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -222,9 +360,26 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl }); withPiStoreEnv({ homeDir: fakeHome, piAgentDir, piSessionDir, bbDataDir, bridgeDir }, () => { + for (const config of [undefined, { discovery: { includeOmp: false } }]) { + assert.deepEqual( + pi + .enumerate({ config }) + .map((candidate) => path.basename(candidate.path)) + .sort(), + [ + "custom-agent.jsonl", + "custom-data.jsonl", + "custom-session.jsonl", + "default-bb.jsonl", + "direct-override.jsonl", + "standalone.jsonl", + ].sort(), + "OMP's default store is not read unless explicitly enabled", + ); + } assert.deepEqual( pi - .enumerate() + .enumerate({ config: { discovery: { includeOmp: true } } }) .map((candidate) => path.basename(candidate.path)) .sort(), [ @@ -233,8 +388,11 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "custom-session.jsonl", "default-bb.jsonl", "direct-override.jsonl", + "omp-standalone.jsonl", "standalone.jsonl", - ], + "Subagent.Child.jsonl", + "Subagent.jsonl", + ].sort(), ); }); diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 9669514..bfa9914 100644 --- a/test/analyze-reuse.test.js +++ b/test/analyze-reuse.test.js @@ -6,6 +6,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import { resolveRepo } from "../src/repo.js"; +import { loadConfig } from "../src/config.js"; +import { discoverForRun } from "../src/commands/scan.js"; import { State } from "../src/state.js"; import { resolveMemoryFiles } from "../src/memory.js"; import { foldForRun } from "../src/commands/propose.js"; @@ -308,3 +311,116 @@ test("old-hash leftover evidence cannot change the current fold's session count, }); }); }); + +test("OMP analysis persists parent observer identity and fold restores it for legacy evidence", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-fold-home-")); + const dir = initRepo(MEMORY); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + + const writeOmpTranscript = (file, id) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const entries = [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: dir }, + { type: "message", message: { role: "user", content: "Please build the project." } }, + { type: "message", message: { role: "assistant", content: "Ran make build as instructed." } }, + { type: "message", message: { role: "user", content: "Now run the tests too." } }, + { type: "message", message: { role: "assistant", content: "Tests pass." } }, + ]; + fs.writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + }; + + writeOmpTranscript(path.join(sessionRoot, `${parentName}.jsonl`), "parent-native"); + writeOmpTranscript(path.join(sessionRoot, parentName, "Subagent.jsonl"), "child-native"); + const disabled = runAnalyze(dir, home); + assert.equal(disabled.status, 0, disabled.output); + assert.equal(JSON.parse(disabled.stdout).transcripts, 0, "the default CLI run does not read OMP sessions"); + fs.writeFileSync(path.join(dir, ".backpassrc.json"), JSON.stringify({ discovery: { includeOmp: true } })); + + const analyzed = runAnalyze(dir, home); + assert.equal(analyzed.status, 0, analyzed.output); + assert.equal(analyzed.summary.analyzed, 2, "the real analyzer writes evidence for parent and child"); + + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + const repo = resolveRepo(dir); + const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + const state = new State(dir).ensure(); + config.state = state; + const ctx = { repo, config, scope: null, strict: false, limit: null }; + const { transcripts } = await discoverForRun(ctx); + assert.equal(transcripts.length, 2, "discovery returns both OMP transcripts"); + assert.equal(new Set(transcripts.map((transcript) => transcript.corroborationIdentity)).size, 1); + + const evidence = state.listEvidence(); + assert.equal(evidence.length, 2); + + const memoryFile = resolveMemoryFiles(dir, ["AGENTS.md", "CLAUDE.md"]).primary; + const memoryHash = evidence[0].memoryHash; + const foldCtx = { repo, config: { ...config, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, scope: null }; + + const current = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(current.gaps.length, 0, "parent and child count as one observer, below the two-session threshold"); + const currentSessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(currentSessionIds, [transcripts[0].corroborationIdentity]); + assert.ok( + evidence.every((record) => + transcripts.some( + (transcript) => + transcript.path === record.transcript.path && + transcript.corroborationIdentity === record.transcript.corroborationIdentity, + ), + ), + "analysis persists the observer identity needed by later folds", + ); + + const childEvidence = evidence.find((record) => record.transcript.path === childPath); + assert.ok(childEvidence); + for (const field of [ + "parentSessionId", + "corroborationIdentity", + "corroborationNativeId", + "corroborationStartedAt", + ]) { + delete childEvidence.transcript[field]; + } + state.writeEvidence(childEvidence.transcript, childEvidence); + + const reused = runAnalyze(dir, home); + assert.equal(reused.status, 0, reused.output); + assert.deepEqual([reused.summary.analyzed, reused.summary.cached], [0, 2]); + const reusedChild = state.listEvidence().find((record) => record.transcript.path === childPath); + assert.equal(reusedChild.transcript.parentSessionId, "parent-native"); + assert.equal(reusedChild.transcript.corroborationIdentity, transcripts[0].corroborationIdentity); + assert.equal(reusedChild.transcript.corroborationNativeId, "parent-native"); + + state.writeGapLedger({ version: 1, entries: {} }); + for (const record of evidence) { + const transcript = { ...record.transcript }; + delete transcript.parentSessionId; + delete transcript.corroborationIdentity; + delete transcript.corroborationNativeId; + delete transcript.corroborationStartedAt; + state.writeEvidence(transcript, { ...record, transcript }); + } + + const legacy = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(legacy.gaps.length, 0, "selected discovery metadata restores identity for older evidence"); + const legacySessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(legacySessionIds, [transcripts[0].corroborationIdentity]); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + } +}); diff --git a/test/config.test.js b/test/config.test.js index d687d3c..f7802a2 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -178,6 +178,15 @@ test("--include-cursor-ide is the only way the deferred store is scanned", () => assert.ok(config.discovery.harnesses.includes("cursor-ide")); }); +test("OMP discovery rejects non-boolean opt-ins rather than treating them as enabled", () => { + for (const includeOmp of ["true", 1, null, []]) { + assert.throws( + () => loadConfig(tempRepo({ discovery: { includeOmp } })), + /config\.discovery\.includeOmp must be a boolean/, + ); + } +}); + test("unknown harness names are dropped rather than failing the run", () => { const config = loadConfig(tempRepo({ discovery: { harnesses: ["claude", "not-a-harness"] } })); assert.deepEqual(config.discovery.harnesses, ["claude"]); diff --git a/test/fixtures/omp-session.jsonl b/test/fixtures/omp-session.jsonl new file mode 100644 index 0000000..7ee3264 --- /dev/null +++ b/test/fixtures/omp-session.jsonl @@ -0,0 +1,6 @@ +{"type":"title","v":1,"title":"","updatedAt":"2026-08-03T08:00:00.000Z","pad":" "} +{"type":"session","version":3,"id":"omp-5678","timestamp":"2026-08-03T08:00:00.000Z","cwd":"/repo/demo"} +{"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-08-03T08:00:00.100Z","model":"cursor/composer-2.5","resolvedModelIsFallback":false} +{"type":"message","id":"e1","parentId":"m1","timestamp":"2026-08-03T08:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Add the changelog entry."}]}} +{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-08-03T08:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning that must be dropped"},{"type":"text","text":"Editing CHANGELOG.md."},{"type":"toolCall","id":"tc1","name":"bash","arguments":{"command":"git status"}}]}} +{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-08-03T08:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","content":[{"type":"text","text":"nothing to commit"}]}} diff --git a/test/fold.test.js b/test/fold.test.js index 3914762..5729701 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -532,6 +532,74 @@ test("harm-class negatives are counted per distinct session, and only explicit h assert.equal(rows.get("AG-001").negative, 4); assert.equal(rows.get("AG-002").harmSessions, 0, "non-compliance and unclassified never count as harm"); }); +test("OMP subagents share corroboration while relevance remains per file", () => { + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: "pi-file-parent", + corroborationIdentity: "pi-parent-session", + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: "pi-parent-session", + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const independent = { + id: "pi-independent", + nativeId: "independent-native", + identity: "pi-file-independent", + corroborationIdentity: "pi-independent-session", + corroborationNativeId: "independent-native", + corroborationStartedAt: startedAt + 2_000, + harness: "pi", + startedAt: startedAt + 2_000, + interaction: "interactive", + }; + const observed = (transcript) => + record(transcript.id, { + transcript, + negative: [ + { instruction: "AG-001", quote: `harm ${transcript.id}`, class: "harm" }, + { instruction: "AG-002", quote: `ignored ${transcript.id}`, class: "non-compliance" }, + ], + gaps: [{ proposedInstruction: "Read the deployment runbook first.", quote: `gap ${transcript.id}` }], + }); + + const parentAndChild = foldEvidence([observed(parent), observed(child)], { minGapEvidence: 2, memoryFile }); + const oneObserver = new Map(parentAndChild.instructions.map((row) => [row.instruction, row])); + assert.equal(parentAndChild.gaps.length, 0, "parent and subagent cannot clear the two-session floor"); + assert.equal(parentAndChild.totals.droppedGapSingletons, 1); + assert.equal(parentAndChild.sources.length, 1, "both files share one visible evidence source"); + assert.equal(oneObserver.get("AG-001").harmSessions, 1); + assert.equal(oneObserver.get("AG-002").nonComplianceSessions, 1); + assert.equal(oneObserver.get("AG-001").sessions, 2, "relevance still measures both analyzed files"); + assert.equal(oneObserver.get("AG-001").relevance, 1); + + const independentlyCorroborated = foldEvidence([observed(parent), observed(child), observed(independent)], { + minGapEvidence: 2, + memoryFile, + }); + const twoObservers = new Map(independentlyCorroborated.instructions.map((row) => [row.instruction, row])); + assert.equal(independentlyCorroborated.gaps.length, 1); + assert.equal(independentlyCorroborated.gaps[0].sessions, 2); + assert.equal(independentlyCorroborated.sources.length, 2); + assert.equal(twoObservers.get("AG-001").harmSessions, 2); + assert.equal(twoObservers.get("AG-002").nonComplianceSessions, 2); + assert.equal(twoObservers.get("AG-001").sessions, 3); +}); test("failed-trigger citations count per skill and reach the synthesis prompt with the cluster", () => { const covered = (id, phrasing) => diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 417ed8c..844e9b3 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -126,6 +126,163 @@ test("the same session is never double-counted across runs", async () => { assert.equal(entries.length, 1, "rephrasings of one gap share one ledger entry"); assert.deepEqual(Object.keys(entries[0].sessions), ["claude-s1"]); }); +test("OMP parent and subagent persist one ledger sighting and keep it in the child sample", async () => { + const h = harness(); + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parentPath = "/omp/sessions/-repo-demo/parent-session.jsonl"; + const parentIdentity = "pi-parent-session"; + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: "pi-file-parent", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: parentPath, + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session/Subagent.jsonl", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const independent = { + id: "claude-independent", + nativeId: "independent", + identity: "independent-session", + corroborationIdentity: "independent-session", + corroborationNativeId: "independent", + corroborationStartedAt: startedAt + 2_000, + harness: "claude", + path: "/claude/independent.jsonl", + startedAt: startedAt + 2_000, + interaction: "interactive", + }; + const evidence = (transcript) => { + const result = record(transcript.id, [GAP]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const parentEvidence = evidence(parent); + const childEvidence = evidence(child); + + const first = await run(h, [parentEvidence, childEvidence]); + assert.equal(first.gaps.length, 0, "a parent plus its subagent remains one observer"); + assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); + + const childOnly = await foldForRun(h.ctx, memoryFile(), "h1", [], [child]); + assert.equal(childOnly.totals.gapSightings, 1, "the sampled child keeps its parent's ledger sighting"); + assert.equal(childOnly.gaps.length, 0); + + const independentEvidence = evidence(independent); + h.state.writeEvidence(independent.id, independentEvidence); + const withIndependent = await foldForRun(h.ctx, memoryFile(), "h1", [], [child, independent]); + assert.equal(withIndependent.gaps.length, 1); + assert.equal(withIndependent.gaps[0].sessions, 2); +}); +test("a selected OMP child collapses legacy parent and child ledger sightings", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parentIdentity = "pi-parent-session"; + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: parentIdentity, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session.jsonl", + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session/Subagent.jsonl", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const asEvidence = (transcript) => { + const result = record(transcript.id, [GAP]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const legacyChild = { ...child }; + delete legacyChild.parentSessionId; + delete legacyChild.corroborationIdentity; + delete legacyChild.corroborationNativeId; + delete legacyChild.corroborationStartedAt; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [asEvidence(parent), asEvidence(legacyChild)], { + now: new Date(startedAt + 2_000), + }); + h.state.writeGapLedger(ledger); + + const currentChildEvidence = asEvidence(child); + currentChildEvidence.gaps = []; + h.state.writeEvidence(child.id, currentChildEvidence); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [child]); + + assert.equal(summary.gaps.length, 0, "old parent and child keys still represent one observer"); + assert.equal(summary.totals.droppedGapSingletons, 1); + assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); +}); + +test("an OMP parent and subagent sharing a root vote project in one run whatever the order", () => { + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const rootIdentity = "pi-parent-session"; + const observe = (id, domain) => { + const transcript = { + id, + nativeId: `${id}-native`, + identity: `pi-file-${id}`, + corroborationIdentity: rootIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt, + interaction: "interactive", + }; + const result = record(id, [{ proposedInstruction: GAP, domain }]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const parent = observe("pi-parent", "project"); + const child = observe("pi-child", "orchestration"); + const domainAfter = (records, ledger = { version: 1, entries: {} }, now = new Date(startedAt + DAY)) => { + recordGapObservations(ledger, records, { now }); + const [entry] = Object.values(ledger.entries); + assert.deepEqual(Object.keys(entry.sessions), [rootIdentity]); + return { ledger, domain: entry.sessions[rootIdentity].domain }; + }; + + assert.equal(domainAfter([parent, child]).domain, "project"); + assert.equal(domainAfter([child, parent]).domain, "project"); + + const { ledger } = domainAfter([parent]); + assert.equal( + domainAfter([child], ledger, new Date(startedAt + 2 * DAY)).domain, + "orchestration", + "a later run still replaces the earlier vote", + ); +}); test("a legacy session-id observation migrates without counting the identity as a second session", async () => { const h = harness(); @@ -145,6 +302,71 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); +test("an ambiguous legacy session id never migrates onto a selected session", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, + }); + const a = shared("session-a"); + const b = shared("session-b"); + const c = record("claude-c", []).transcript; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [record("claude-shared", [GAP]), record("claude-c", [GAP])]); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); + + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a, c]); + + assert.equal(summary.gaps.length, 0, "a sighting that may be B's must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-c", "claude-shared"]); +}); + +test("a selected session recording a gap never inherits an ambiguous legacy id's sighting", async () => { + const h = harness(); + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, + }); + const a = shared("session-a"); + const b = shared("session-b"); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const ledger = { version: 1, entries: {} }; + const bFirstObservedAt = new Date(Date.now() - 80 * DAY); + recordGapObservations(ledger, [record("claude-shared", [GAP])], { now: bFirstObservedAt }); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [GAP])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); + + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a]); + + assert.equal(summary.gaps.length, 0, "B's sighting must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-shared", "session-a"]); + assert.equal(entry.sessions["claude-shared"].firstObservedAt, bFirstObservedAt.toISOString()); + assert.ok( + Date.parse(entry.sessions["session-a"].firstObservedAt) > bFirstObservedAt.getTime(), + "A's fresh sighting keeps its own first-seen time", + ); +}); + test("a genuine one-off never graduates, however many runs see it", async () => { const h = harness(); for (let i = 0; i < 5; i += 1) { diff --git a/test/interaction.test.js b/test/interaction.test.js index 2ede9b1..465ea1c 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -25,6 +25,7 @@ import { foldForRun, printProposal } from "../src/commands/propose.js"; import { cmdScan } from "../src/commands/scan.js"; import { renderApplySurface } from "../src/apply/lavish.js"; import { evidenceKey, State } from "../src/state.js"; +import { recordGapObservations } from "../src/gap-ledger.js"; import { transcriptIdentity } from "../src/transcript.js"; import { sampleTranscripts, capTranscripts } from "../src/sample.js"; import { setLoggerSink } from "../src/logger.js"; @@ -478,6 +479,44 @@ test("fold excludes legacy evidence without an interaction category", async () = assert.equal(state.readEvidence(transcript).transcript.interaction, undefined); }); +test("fold keeps legacy evidence excluded when current discovery stamps an interaction", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-fold-current-")); + const state = new State(dir).ensure(); + const transcript = { + harness: "codex", + id: "codex-legacy-current", + nativeId: "legacy-current", + path: "/sessions/legacy-current.jsonl", + mtimeMs: 100, + bytes: 200, + interaction: INTERACTIVE, + }; + const memoryHash = "sha256:memory"; + state.writeEvidence(transcript, { + status: "ok", + transcript: { harness: "codex", id: transcript.id, path: transcript.path }, + memoryHash, + memoryPath: "AGENTS.md", + key: evidenceKey(transcript, memoryHash), + positive: [{ instruction: "AG-001", quote: "followed the repository rule exactly" }], + negative: [], + gaps: [], + }); + + const summary = await foldForRun( + { + repo: { root: dir }, + config: { state, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, + }, + { path: "AGENTS.md", text: "", units: [] }, + memoryHash, + [], + [transcript], + ); + + assert.equal(summary.analyzedSessions, 0, "only analysis may backfill a stored interaction stamp"); +}); + test("fold selection distinguishes colliding native IDs by source", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-identity-fold-")); const state = new State(dir).ensure(); @@ -749,6 +788,241 @@ test("evidence records carry the category and fold reports relevance per categor process.env.HOME = prevHome; } }); +test("OMP subagents share their parent identity and refresh cached relations", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(parentPath, header("parent-native")); + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const cache = config.state.readScanCache(); + for (const candidate of pi.enumerate({ config })) { + const descriptor = pi.classify(candidate); + delete descriptor.parentSessionId; + delete descriptor.parentSessionPath; + delete descriptor.parentSessionStartedAt; + cache.entries[`pi:${candidate.key}`] = { + mtimeMs: candidate.mtimeMs, + bytes: candidate.bytes, + descriptor, + }; + } + config.state.writeScanCache(cache); + + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(first.perHarness.pi.cached, 0, "the pre-relation cache must be reclassified"); + const parent = first.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity, "the two files remain separately analyzable"); + assert.equal(parent.corroborationIdentity, parent.identity); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(parent.interaction, INTERACTIVE); + assert.equal(child.interaction, NON_INTERACTIVE); + + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 2, "the refreshed relation is safe to reuse"); + assert.equal( + second.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + config.discovery.includeOmp = false; + const disabled = await discoverTranscripts({ repo: repository, config }); + assert.deepEqual(disabled.transcripts, [], "opting out must not replay cached OMP descriptors"); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("Pi child cache is invalidated when its parent session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-cache-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + + const first = await discoverTranscripts({ repo: repository, config }); + const childBeforeParent = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(childBeforeParent); + assert.equal(childBeforeParent.parentSessionId, undefined); + + writeJsonl(parentPath, header("parent-native")); + const second = await discoverTranscripts({ repo: repository, config }); + const parent = second.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = second.transcripts.find((transcript) => transcript.nativeId === "child-native"); + + assert.ok(parent && child); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(second.perHarness.pi.cached, 0, "the child descriptor must be reclassified after its parent appears"); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 2, "the refreshed parent relation is safe to reuse"); + assert.equal( + third.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("nested OMP subagents share the root identity once the root session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, timestamp) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp, cwd: repo }, + ]; + writeJsonl(childPath, header("child-native", "2026-08-27T00:01:00.000Z")); + writeJsonl(grandchildPath, header("grandchild-native", "2026-08-27T00:02:00.000Z")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + config.gapLedgerMaxAge = "all"; + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const byNativeId = (result, nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(byNativeId(first, "child-native").parentSessionId, undefined); + assert.equal(byNativeId(first, "grandchild-native").parentSessionId, "child-native"); + + writeJsonl(rootPath, header("root-native", "2026-08-27T00:00:00.000Z")); + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 0, "both descendants must be reclassified when the root appears"); + const root = byNativeId(second, "root-native"); + const child = byNativeId(second, "child-native"); + const grandchild = byNativeId(second, "grandchild-native"); + assert.ok(root && child && grandchild); + assert.equal(new Set([root.identity, child.identity, grandchild.identity]).size, 3, "each file stays analyzable"); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.corroborationStartedAt, root.startedAt); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.corroborationIdentity, root.identity); + assert.equal(root.interaction, INTERACTIVE); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 3, "the refreshed relations are safe to reuse"); + assert.equal(byNativeId(third, "grandchild-native").corroborationIdentity, root.identity); + + const perFile = (transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + memoryHash: "sha256:memory", + transcript: { ...transcript, parentSessionId: null, corroborationIdentity: null, corroborationNativeId: null }, + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived it", + quote: "quote", + recurrenceRisk: "high", + }, + ], + }); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [perFile(child), perFile(grandchild)]); + config.state.writeGapLedger(ledger); + + const summary = await foldForRun( + { repo: { root: repo }, config }, + { path: "AGENTS.md", text: "", units: [] }, + "sha256:memory", + [], + [child, grandchild], + ); + assert.equal(summary.gaps.length, 0, "per-file sightings of one root session are one observer"); + const [entry] = Object.values(config.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions), [root.identity]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("OMP subagents running in another repo cwd still share the root identity", async () => { + const repo = initRepo(); + const subdir = path.join(repo, "packages", "api"); + fs.mkdirSync(subdir, { recursive: true }); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, cwd) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd }, + ]; + writeJsonl(rootPath, header("root-native", repo)); + writeJsonl(childPath, header("child-native", subdir)); + writeJsonl(grandchildPath, header("grandchild-native", subdir)); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const result = await discoverTranscripts({ repo: repository, config }); + const byNativeId = (nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + const root = byNativeId("root-native"); + const child = byNativeId("child-native"); + const grandchild = byNativeId("grandchild-native"); + + assert.ok(root && child && grandchild, "a subagent in a repo subdirectory still maps to this repository"); + assert.equal(child.cwd, subdir); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.interaction, INTERACTIVE); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); test("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index 9658a6d..2ea5c9b 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -40,6 +40,62 @@ function scenario({ variant = {}, cwdOverride = null, sessionText = null } = {}) }; } +test("remote OMP subagents keep their parent's corroboration identity", async () => { + const s = scenario(); + const sessionDir = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const writeSession = (file, id, timestamp) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "" })}\n` + + `${JSON.stringify({ + type: "session", + version: 3, + id, + timestamp, + cwd: s.remoteClone, + })}\n`, + ); + }; + writeSession(parentPath, "parent-native", "2026-08-27T00:00:00.000Z"); + writeSession(childPath, "child-native", "2026-08-27T00:01:00.000Z"); + + const piEnv = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "BB_DATA_DIR", "BB_PI_BRIDGE_SESSION_DIR"]; + const previous = Object.fromEntries(piEnv.map((key) => [key, process.env[key]])); + for (const key of piEnv) delete process.env[key]; + let result; + try { + const disabled = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all" }, + }), + ); + assert.deepEqual(disabled.transcripts, [], "the remote OMP store is opt-in too"); + result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); + } finally { + for (const key of piEnv) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + + const parent = result.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = result.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(child.corroborationNativeId, "parent-native"); + assert.equal(child.corroborationStartedAt, parent.startedAt); +}); + test("host collection has plain progress without duplicating live progress", async () => { const plain = scenario(); const lines = [];