From 62f5d3e5416cfd451d4dfa179bb60b93dba0349c Mon Sep 17 00:00:00 2001 From: mdc2122 Date: Tue, 15 Sep 2026 22:22:06 -0500 Subject: [PATCH 01/10] feat(discovery): discover omp session transcripts in the pi adapter omp (Oh My Pi) writes the same version:3 JSONL session format as pi under ~/.omp/agent/sessions/, but prepends a fixed-width {type:"title"} record so the {type:"session", cwd} entry is line 2, and nests subagent transcripts one level deeper at //.jsonl. - Add ~/.omp/agent/sessions as a default nested store root (omp honors PI_CODING_AGENT_DIR, so an env redirect still dedupes against it). - classify() scans the first head lines for the session entry instead of requiring it on line 1. - enumerate() descends one extra level inside nested roots. - read() accepts model_change.model as a fallback for modelId. --- src/discovery/adapters/pi.js | 51 +++++++++++++++++++++------------ test/adapters.test.js | 34 +++++++++++++++++++++- test/fixtures/omp-session.jsonl | 6 ++++ 3 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 test/fixtures/omp-session.jsonl diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 82e5e1fc..1b841c36 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -16,12 +16,17 @@ 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/` 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`. 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 + * 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. + * 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"; @@ -49,6 +54,7 @@ function realpathOrResolve(value) { function storeSpecs() { const specs = [ { path: storeRoot(), direct: false, nested: true }, + { path: home(".omp", "agent", "sessions"), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); @@ -84,7 +90,13 @@ export function enumerate() { for (const spec of storeSpecs()) { 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) => [ + ...listFiles(dir, ".jsonl"), + // omp nests subagent transcripts one level below the session files. + ...listDirs(dir).flatMap((sub) => listFiles(sub, ".jsonl")), + ]) + : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -99,18 +111,21 @@ export function enumerate() { } export function classify(candidate) { - const [first] = readHeadLines(candidate.path, 1); - const entry = first && parseJsonLine(first); - if (!entry || entry.type !== "session" || !entry.cwd) return null; - return { - id: entry.id || path.basename(candidate.path, ".jsonl"), - cwd: entry.cwd, - gitBranch: null, - remotes: [], - startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, - model: null, - interactionSignals: emptyInteractionSignals(), - }; + // omp prepends a fixed-width title record, so the session entry is not always line 1. + for (const line of readHeadLines(candidate.path, 8)) { + const entry = parseJsonLine(line); + if (!entry || entry.type !== "session" || !entry.cwd) continue; + return { + id: entry.id || path.basename(candidate.path, ".jsonl"), + cwd: entry.cwd, + gitBranch: null, + remotes: [], + startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, + model: null, + interactionSignals: emptyInteractionSignals(), + }; + } + return null; } export function read(ref) { @@ -120,7 +135,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/test/adapters.test.js b/test/adapters.test.js index d95bacd9..bf60f4bb 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -151,6 +151,28 @@ 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"); +}); + +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 +222,14 @@ 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", + }); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -233,8 +263,10 @@ 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.jsonl", + ].sort(), ); }); diff --git a/test/fixtures/omp-session.jsonl b/test/fixtures/omp-session.jsonl new file mode 100644 index 00000000..7ee32648 --- /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"}]}} From bb100f43dfa7cf7715b03638d2f357240542e085 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 00:41:29 -0500 Subject: [PATCH 02/10] fix(discovery): share OMP corroboration across subagents --- README.md | 16 ++-- src/acpx.js | 3 +- src/analyze.js | 6 +- src/commands/propose.js | 51 +++++++---- src/discovery/adapters/pi.js | 87 +++++++++++++++--- src/discovery/index.js | 33 ++++++- src/discovery/remote/probe.js | 9 +- src/fold.js | 12 ++- src/gap-ledger.js | 100 ++++++++++++++++++-- src/interaction.js | 1 + src/transcript.js | 5 + test/adapters.test.js | 55 +++++++++++ test/analyze-reuse.test.js | 112 +++++++++++++++++++++++ test/fold.test.js | 68 ++++++++++++++ test/gap-ledger.test.js | 168 ++++++++++++++++++++++++++++++++++ test/interaction.test.js | 103 +++++++++++++++++++++ test/remote-discovery.test.js | 50 ++++++++++ 17 files changed, 818 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 799a0911..e839a9a1 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,10 @@ 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 nests subagent JSONL files below each parent session. Backpass analyzes each file +separately, but uses the parent session as their shared corroboration source; a parent 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 +293,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 diff --git a/src/acpx.js b/src/acpx.js index 50197339..9102e1fd 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 a45be6f6..7bbcf785 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 9d59b56f..fb0ba771 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"; /** @@ -23,17 +28,16 @@ import { pruneHostCache } from "../discovery/cache.js"; * the evidence files that fed an expired sighting are still on disk and would re-add it), * then cluster from the ledger. * - * Evidence is filtered to selected transcript identities, the current memory hash and - * analysis-index cache key, and a valid interaction category. Reanalysis rewrites a - * transcript's evidence when an input changes, but records outside this run's window or - * 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. + * Evidence is filtered to selected transcript identities, the current memory hash, and + * the analysis-index cache key. Current discovery metadata is overlaid on persisted + * records for folding, restoring observer and interaction details for legacy evidence + * without admitting records outside this run's sample. Folding leftover records would + * inflate `analyzedSessions` beyond the sampled corpus or score positional instruction + * aliases against an index they never saw. */ 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,25 +46,32 @@ 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()); 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); } } - 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 || + !currentTranscript || + !isEvidenceFresh(record, currentTranscript, memoryHash) + ) { + continue; + } + const transcript = { ...record.transcript, ...currentTranscript }; + if (transcript.interaction !== INTERACTIVE && transcript.interaction !== NON_INTERACTIVE) continue; + relevant.push({ ...record, transcript }); + } const ledger = state.readGapLedger(); + normalizeGapLedgerSessions(ledger, transcripts); recordGapObservations(ledger, relevant, { skills }); // 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 diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 1b841c36..a8b4c2df 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -30,6 +30,7 @@ import { */ export const name = "pi"; +export const cacheVersion = 3; export function storeRoot() { return home(".pi", "agent", "sessions"); @@ -110,22 +111,78 @@ export function enumerate() { return out; } -export function classify(candidate) { - // omp prepends a fixed-width title record, so the session entry is not always line 1. - for (const line of readHeadLines(candidate.path, 8)) { - const entry = parseJsonLine(line); - if (!entry || entry.type !== "session" || !entry.cwd) continue; - return { - id: entry.id || path.basename(candidate.path, ".jsonl"), - cwd: entry.cwd, - gitBranch: null, - remotes: [], - startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, - model: null, - interactionSignals: emptyInteractionSignals(), - }; +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`); +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function cacheDependency(candidate, options = {}) { + return readParentSession(parentSessionPathFor(candidate.path), 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; + + const descriptor = { + id: entry.id || path.basename(candidate.path, ".jsonl"), + cwd: entry.cwd, + gitBranch: null, + remotes: [], + startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, + model: null, + interactionSignals: emptyInteractionSignals(), + }; + + const parentPath = parentSessionPathFor(candidate.path); + const parentInfo = readParentSession(parentPath, scanContext); + const parent = parentInfo?.entry; + if (parent?.type === "session" && parent.cwd === entry.cwd) { + descriptor.parentSessionId = parent.id || path.basename(parentPath, ".jsonl"); + descriptor.parentSessionPath = parentPath; + descriptor.parentSessionStartedAt = parent.timestamp ? Date.parse(parent.timestamp) : parentInfo.stat.mtimeMs; } - return null; + + return descriptor; } export function read(ref) { diff --git a/src/discovery/index.js b/src/discovery/index.js index 87b62698..6b467090 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -316,8 +316,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 +336,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 +422,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 1d55f98f..145460bd 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, @@ -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 }); + 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 }; diff --git a/src/fold.js b/src/fold.js index 768c2823..2ae0152e 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 e405c303..e6171746 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`). @@ -70,9 +71,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,6 +165,12 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } +function sessionIdentityAliases(transcript, sessionIdentity) { + return [...new Set([transcript.identity, transcript.id])].filter( + (identity) => identity && identity !== sessionIdentity, + ); +} + /** * 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. @@ -175,8 +184,8 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { 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,14 +215,15 @@ 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 aliases = sessionIdentityAliases(transcript, sessionIdentity); + 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]; - if (aliasPrior) delete entry.sessions[transcript.id]; + for (const alias of aliases) delete entry.sessions[alias]; const coveredBySkill = gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; const phrasings = [ @@ -228,7 +238,10 @@ 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, @@ -248,6 +261,75 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } +/** Re-key selected sessions in old ledgers when related transcript files now share an identity. */ +export function normalizeGapLedgerSessions(ledger, transcripts) { + const selections = []; + const selectionsByAlias = new Map(); + + for (const transcript of transcripts) { + if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + const aliases = sessionIdentityAliases(transcript, sessionIdentity); + if (!aliases.length) continue; + + const index = selections.length; + selections.push({ transcript, sessionIdentity, aliases }); + for (const alias of aliases) { + let indexes = selectionsByAlias.get(alias); + if (!indexes) selectionsByAlias.set(alias, (indexes = [])); + indexes.push(index); + } + } + + if (!selections.length) return; + for (const entry of Object.values(ledger.entries)) { + const pending = new Set(); + for (const identity of Object.keys(entry.sessions)) { + const indexes = selectionsByAlias.get(identity); + if (indexes) for (const index of indexes) pending.add(index); + } + + for (let index = 0; index < selections.length; index += 1) { + if (!pending.has(index)) continue; + const { transcript, sessionIdentity, aliases } = selections[index]; + if (!aliases.some((identity) => entry.sessions[identity])) continue; + const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + Boolean, + ); + const current = entry.sessions[sessionIdentity] || priors[0]; + 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; + 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 } : {}), + }; + for (const alias of aliases) delete entry.sessions[alias]; + + const next = selectionsByAlias.get(sessionIdentity); + if (next) for (const nextIndex of next) if (nextIndex > index) pending.add(nextIndex); + } + } +} /** * Retire observations that no longer count: sightings first seen more than `maxAge` ago diff --git a/src/interaction.js b/src/interaction.js index 020f1fa8..bcdde1bb 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -60,6 +60,7 @@ function claudeEntrypointIsNonInteractive(entrypoint) { * 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 32dc519e..d39a7890 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 bf60f4bb..1930aa3d 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -163,7 +163,62 @@ test("pi adapter classifies omp sessions past the title record and reads model", 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 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( diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 96695143..6acdd90d 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,112 @@ 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 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" } }); + 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/fold.test.js b/test/fold.test.js index 39147622..57297010 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 417ed8c7..d46c0461 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -10,6 +10,7 @@ import { foldForRun } from "../src/commands/propose.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; import { gapEntryId, + normalizeGapLedgerSessions, ledgerGapObservations, mergeGapEntries, pruneGapLedger, @@ -126,6 +127,123 @@ 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("a legacy session-id observation migrates without counting the identity as a second session", async () => { const h = harness(); @@ -145,6 +263,56 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); +test("OMP ledger migration preserves selected order in one ledger pass", () => { + const ledger = { + version: 1, + entries: { + [gapEntryId(MEMORY_PATH, GAP)]: { + id: gapEntryId(MEMORY_PATH, GAP), + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: { + "parent-file": { firstObservedAt: "2026-08-01T00:00:00.000Z", project: "parent-project" }, + "child-file": { firstObservedAt: "2026-08-02T00:00:00.000Z", project: "child-project" }, + }, + }, + }, + }; + let ledgerPasses = 0; + const entries = ledger.entries; + ledger.entries = new Proxy(entries, { + ownKeys(target) { + ledgerPasses += 1; + return Reflect.ownKeys(target); + }, + }); + const parent = { + id: "pi-parent", + identity: "parent-file", + corroborationIdentity: "pi-parent", + corroborationNativeId: "parent-native", + harness: "pi", + path: "/omp/sessions/parent.jsonl", + startedAt: Date.parse("2026-08-01T00:00:00.000Z"), + }; + const child = { + id: "pi-child", + identity: "child-file", + corroborationIdentity: "pi-parent", + corroborationNativeId: "parent-native", + harness: "pi", + path: "/omp/sessions/parent/Subagent.jsonl", + startedAt: Date.parse("2026-08-02T00:00:00.000Z"), + }; + + normalizeGapLedgerSessions(ledger, [parent, child]); + assert.equal(ledgerPasses, 1, "the selected transcripts should share one ledger traversal"); + + const [entry] = Object.values(ledger.entries); + assert.deepEqual(Object.keys(entry.sessions), ["pi-parent"]); + assert.equal(entry.sessions["pi-parent"].project, "parent-project"); +}); + 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 2ede9b16..84d45355 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -749,6 +749,109 @@ 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" } }); + config.state = new State(repo).ensure(); + const cache = config.state.readScanCache(); + for (const candidate of pi.enumerate()) { + 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, + ); + } 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" } }); + 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("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 9658a6d6..fd509e0e 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -40,6 +40,56 @@ 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 { + result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all" }, + }), + ); + } 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 = []; From 6ae6d9ae23876c55c09c1dbe0ea7101eb1f66bf9 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 12:54:10 -0500 Subject: [PATCH 03/10] no-mistakes(review): Guard ledger id migration, narrow fold overlay, nest OMP subagents --- README.md | 12 ++-- src/commands/propose.js | 28 +++++--- src/discovery/adapters/pi.js | 53 ++++++++++----- src/gap-ledger.js | 39 ++++------- test/adapters.test.js | 32 +++++++++ test/gap-ledger.test.js | 69 +++++++------------- test/interaction.test.js | 123 +++++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index e839a9a1..f7e93c5e 100644 --- a/README.md +++ b/README.md @@ -232,15 +232,17 @@ relocated config dir does not hide its sessions. The variable is read from backp environment: if you reach that profile through an alias that only prefixes `claude`, set it for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it). -Pi collection covers standalone sessions under `~/.pi/agent/sessions/` and BB-managed Pi -sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR`, +Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under +`~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It +also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots overlap, backpass scans every applicable layout and reads each JSONL file once. -OMP nests subagent JSONL files below each parent session. Backpass analyzes each file -separately, but uses the parent session as their shared corroboration source; a parent and -its subagents cannot count as independent sessions. +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. diff --git a/src/commands/propose.js b/src/commands/propose.js index fb0ba771..f5472b35 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -28,12 +28,14 @@ import { pruneHostCache } from "../discovery/cache.js"; * the evidence files that fed an expired sighting are still on disk and would re-add it), * then cluster from the ledger. * - * Evidence is filtered to selected transcript identities, the current memory hash, and - * the analysis-index cache key. Current discovery metadata is overlaid on persisted - * records for folding, restoring observer and interaction details for legacy evidence - * without admitting records outside this run's sample. Folding leftover records would - * inflate `analyzedSessions` beyond the sampled corpus or score positional instruction - * aliases against an index they never saw. + * Evidence is filtered to selected transcript identities, the current memory hash and + * analysis-index cache key, and a valid interaction category. Reanalysis rewrites a + * transcript's evidence when an input changes, but records outside this run's window or + * 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; @@ -47,11 +49,13 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans identitiesByLegacyId.get(legacyId).add(transcriptIdentity(record.transcript)); } 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 = []; @@ -60,18 +64,24 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans 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, ...currentTranscript }; - if (transcript.interaction !== INTERACTIVE && transcript.interaction !== NON_INTERACTIVE) 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(); - normalizeGapLedgerSessions(ledger, transcripts); + normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); recordGapObservations(ledger, relevant, { skills }); // 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 diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index a8b4c2df..2f5f94c9 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -20,8 +20,10 @@ import { * same JSONL shape under `~/.omp/agent/sessions/` 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`. BB's Pi bridge writes the same JSONL shape - * directly under `/pi-bridge-sessions/`. + * `//.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/`. * * Entries form a parent/child tree but arrive in * order, so a linear read is faithful. `model_change` / `thinking_level_change` records @@ -32,6 +34,8 @@ import { export const name = "pi"; export const cacheVersion = 3; +const SUBAGENT_DEPTH = 2; + export function storeRoot() { return home(".pi", "agent", "sessions"); } @@ -91,13 +95,7 @@ export function enumerate() { for (const spec of storeSpecs()) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested - ? listDirs(spec.path).flatMap((dir) => [ - ...listFiles(dir, ".jsonl"), - // omp nests subagent transcripts one level below the session files. - ...listDirs(dir).flatMap((sub) => listFiles(sub, ".jsonl")), - ]) - : []), + ...(spec.nested ? listDirs(spec.path).flatMap((dir) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -111,6 +109,13 @@ export function enumerate() { return out; } +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() }; } @@ -152,9 +157,23 @@ function parentSessionPathFor(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 readParentSession(parentSessionPathFor(candidate.path), options.scanContext)?.fingerprint ?? null; + return JSON.stringify( + ancestorSessionPaths(candidate.path).map( + (ancestorPath) => readParentSession(ancestorPath, options.scanContext)?.fingerprint ?? null, + ), + ); } /** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ @@ -173,13 +192,13 @@ export function classify(candidate, options = {}) { interactionSignals: emptyInteractionSignals(), }; - const parentPath = parentSessionPathFor(candidate.path); - const parentInfo = readParentSession(parentPath, scanContext); - const parent = parentInfo?.entry; - if (parent?.type === "session" && parent.cwd === entry.cwd) { - descriptor.parentSessionId = parent.id || path.basename(parentPath, ".jsonl"); - descriptor.parentSessionPath = parentPath; - descriptor.parentSessionStartedAt = parent.timestamp ? Date.parse(parent.timestamp) : parentInfo.stat.mtimeMs; + for (const ancestorPath of ancestorSessionPaths(candidate.path)) { + const ancestorInfo = readParentSession(ancestorPath, scanContext); + const ancestor = ancestorInfo?.entry; + if (ancestor?.cwd !== entry.cwd) 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; diff --git a/src/gap-ledger.js b/src/gap-ledger.js index e6171746..eacd1a93 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -261,37 +261,25 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } -/** Re-key selected sessions in old ledgers when related transcript files now share an identity. */ -export function normalizeGapLedgerSessions(ledger, transcripts) { +/** + * 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 = []; - const selectionsByAlias = new Map(); - for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); - const aliases = sessionIdentityAliases(transcript, sessionIdentity); - if (!aliases.length) continue; - - const index = selections.length; - selections.push({ transcript, sessionIdentity, aliases }); - for (const alias of aliases) { - let indexes = selectionsByAlias.get(alias); - if (!indexes) selectionsByAlias.set(alias, (indexes = [])); - indexes.push(index); - } + const aliases = sessionIdentityAliases(transcript, sessionIdentity).filter( + (alias) => alias !== transcript.id || legacyIds.has(alias), + ); + if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); } - if (!selections.length) return; for (const entry of Object.values(ledger.entries)) { - const pending = new Set(); - for (const identity of Object.keys(entry.sessions)) { - const indexes = selectionsByAlias.get(identity); - if (indexes) for (const index of indexes) pending.add(index); - } - - for (let index = 0; index < selections.length; index += 1) { - if (!pending.has(index)) continue; - const { transcript, sessionIdentity, aliases } = selections[index]; + for (const { transcript, sessionIdentity, aliases } of selections) { if (!aliases.some((identity) => entry.sessions[identity])) continue; const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, @@ -324,9 +312,6 @@ export function normalizeGapLedgerSessions(ledger, transcripts) { ...(projectRoot ? { projectRoot } : {}), }; for (const alias of aliases) delete entry.sessions[alias]; - - const next = selectionsByAlias.get(sessionIdentity); - if (next) for (const nextIndex of next) if (nextIndex > index) pending.add(nextIndex); } } } diff --git a/test/adapters.test.js b/test/adapters.test.js index 1930aa3d..877decf2 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -194,6 +194,24 @@ test("pi adapter links an OMP subagent to its sibling parent session", () => { 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 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"); @@ -285,6 +303,19 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl 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", @@ -320,6 +351,7 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "direct-override.jsonl", "omp-standalone.jsonl", "standalone.jsonl", + "Subagent.Child.jsonl", "Subagent.jsonl", ].sort(), ); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index d46c0461..fc8e33dc 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -10,7 +10,6 @@ import { foldForRun } from "../src/commands/propose.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; import { gapEntryId, - normalizeGapLedgerSessions, ledgerGapObservations, mergeGapEntries, pruneGapLedger, @@ -263,54 +262,34 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); -test("OMP ledger migration preserves selected order in one ledger pass", () => { - const ledger = { - version: 1, - entries: { - [gapEntryId(MEMORY_PATH, GAP)]: { - id: gapEntryId(MEMORY_PATH, GAP), - memoryPath: MEMORY_PATH, - proposedInstruction: GAP, - sessions: { - "parent-file": { firstObservedAt: "2026-08-01T00:00:00.000Z", project: "parent-project" }, - "child-file": { firstObservedAt: "2026-08-02T00:00:00.000Z", project: "child-project" }, - }, - }, - }, +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; }; - let ledgerPasses = 0; - const entries = ledger.entries; - ledger.entries = new Proxy(entries, { - ownKeys(target) { - ledgerPasses += 1; - return Reflect.ownKeys(target); - }, + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, }); - const parent = { - id: "pi-parent", - identity: "parent-file", - corroborationIdentity: "pi-parent", - corroborationNativeId: "parent-native", - harness: "pi", - path: "/omp/sessions/parent.jsonl", - startedAt: Date.parse("2026-08-01T00:00:00.000Z"), - }; - const child = { - id: "pi-child", - identity: "child-file", - corroborationIdentity: "pi-parent", - corroborationNativeId: "parent-native", - harness: "pi", - path: "/omp/sessions/parent/Subagent.jsonl", - startedAt: Date.parse("2026-08-02T00:00:00.000Z"), - }; + 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])); - normalizeGapLedgerSessions(ledger, [parent, child]); - assert.equal(ledgerPasses, 1, "the selected transcripts should share one ledger traversal"); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a, c]); - const [entry] = Object.values(ledger.entries); - assert.deepEqual(Object.keys(entry.sessions), ["pi-parent"]); - assert.equal(entry.sessions["pi-parent"].project, "parent-project"); + 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 genuine one-off never graduates, however many runs see it", async () => { diff --git a/test/interaction.test.js b/test/interaction.test.js index 84d45355..8cc64f21 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(); @@ -853,6 +892,90 @@ test("Pi child cache is invalidated when its parent session appears", async () = } }); +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" } }); + 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("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ harness: "claude", From 6a63d70c0d6bde0f08126bab4ea8b6ca3907c95e Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 12:59:50 -0500 Subject: [PATCH 04/10] no-mistakes(review): Drop graft cache files, guard ledger recording aliases --- src/commands/propose.js | 2 +- src/gap-ledger.js | 15 +++++++-------- test/gap-ledger.test.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/commands/propose.js b/src/commands/propose.js index f5472b35..04522c3e 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -82,7 +82,7 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans const ledger = state.readGapLedger(); normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); - recordGapObservations(ledger, relevant, { skills }); + 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/gap-ledger.js b/src/gap-ledger.js index eacd1a93..b743ee58 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -165,20 +165,21 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } -function sessionIdentityAliases(transcript, sessionIdentity) { +function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { return [...new Set([transcript.identity, transcript.id])].filter( - (identity) => identity && identity !== sessionIdentity, + (identity) => identity && identity !== sessionIdentity && (identity !== transcript.id || legacyIds.has(identity)), ); } /** * 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) { @@ -215,7 +216,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const aliases = sessionIdentityAliases(transcript, sessionIdentity); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, ); @@ -272,9 +273,7 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); - const aliases = sessionIdentityAliases(transcript, sessionIdentity).filter( - (alias) => alias !== transcript.id || legacyIds.has(alias), - ); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); } diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index fc8e33dc..9a9f13ef 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -292,6 +292,41 @@ test("an ambiguous legacy session id never migrates onto a selected session", as 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) { From 18f7cf617f524af5fd2201ac37eb9e460beb92e8 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:43:13 -0500 Subject: [PATCH 05/10] no-mistakes(review): Make shared OMP domain vote order-independent --- src/gap-ledger.js | 6 +++++- test/gap-ledger.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/gap-ledger.js b/src/gap-ledger.js index b743ee58..ab8cf1d2 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -249,7 +249,11 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { 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. diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 9a9f13ef..844e9b30 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -244,6 +244,46 @@ test("a selected OMP child collapses legacy parent and child ledger sightings", 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(); await run(h, [record("claude-s1", [GAP])]); From cfdb19bede8cec192e452339ff9c1425dec151bb Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:08:36 -0500 Subject: [PATCH 06/10] no-mistakes(review): Link OMP subagents by nested path, share ledger prior merge --- src/discovery/adapters/pi.js | 4 ++-- src/gap-ledger.js | 41 ++++++++++++++++---------------- test/adapters.test.js | 22 ++++++++++++++++++ test/interaction.test.js | 45 ++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 2f5f94c9..c60cdad8 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -32,7 +32,7 @@ import { */ export const name = "pi"; -export const cacheVersion = 3; +export const cacheVersion = 4; const SUBAGENT_DEPTH = 2; @@ -195,7 +195,7 @@ export function classify(candidate, options = {}) { for (const ancestorPath of ancestorSessionPaths(candidate.path)) { const ancestorInfo = readParentSession(ancestorPath, scanContext); const ancestor = ancestorInfo?.entry; - if (ancestor?.cwd !== entry.cwd) continue; + 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; diff --git a/src/gap-ledger.js b/src/gap-ledger.js index ab8cf1d2..72e97557 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -171,6 +171,19 @@ function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { ); } +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. @@ -216,17 +229,13 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); - const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( - Boolean, + const prior = takePriorObservations( + entry, + sessionIdentity, + sessionIdentityAliases(transcript, sessionIdentity, legacyIds), ); - 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]; - for (const alias of aliases) delete entry.sessions[alias]; - const coveredBySkill = - gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + const { priors, firstObservedAt } = prior; + const coveredBySkill = gap.coveredBySkill || prior.coveredBySkill; const phrasings = [ ...new Set([ ...priors.flatMap( @@ -284,15 +293,8 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne 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 = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( - Boolean, - ); - const current = entry.sessions[sessionIdentity] || priors[0]; - 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; + 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; @@ -314,7 +316,6 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne ...(project ? { project } : {}), ...(projectRoot ? { projectRoot } : {}), }; - for (const alias of aliases) delete entry.sessions[alias]; } } } diff --git a/test/adapters.test.js b/test/adapters.test.js index 877decf2..2b4fa11e 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -212,6 +212,28 @@ test("pi adapter links a second-level OMP subagent to the root session", () => { 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"); diff --git a/test/interaction.test.js b/test/interaction.test.js index 8cc64f21..226941c1 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -976,6 +976,51 @@ test("nested OMP subagents share the root identity once the root session appears } }); +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" } }); + 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) => ({ harness: "claude", From 1b8ba492a9582326a3eb7c20a4afa1951ee7f87a Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:44:25 -0500 Subject: [PATCH 07/10] no-mistakes(document): Sync OMP interaction and scan-cache doc comments --- src/discovery/index.js | 6 ++++-- src/interaction.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/discovery/index.js b/src/discovery/index.js index 6b467090..0a89d0f5 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -39,8 +39,10 @@ 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 + * 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, diff --git a/src/interaction.js b/src/interaction.js index bcdde1bb..807ebd44 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -6,8 +6,8 @@ * * 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 + * 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). */ From 4809bae86a37e03cd77cde43d003e587a3712261 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:51:54 -0500 Subject: [PATCH 08/10] chore: ignore local Graft cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 090be833..f22397f3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .DS_Store *.log package-lock.json +graft/.cache/ From 77185bf99da99f05b848f1d47d84b3b0696286a0 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 16:15:09 -0500 Subject: [PATCH 09/10] no-mistakes(document): Sync OMP identity docs and rewrap long comments --- README.md | 9 ++++----- src/discovery/adapters/pi.js | 8 ++++---- src/discovery/index.js | 5 +++-- src/gap-ledger.js | 12 ++++++++---- src/interaction.js | 9 ++++++--- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f7e93c5e..a4bad21a 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ backpass reads the local transcript stores of seven harnesses directly. No API, | -------------- | ---------------------------------------------- | --------------------------------------------------- | | **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` | +| **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` | @@ -234,10 +234,9 @@ for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under `~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It -also honors `PI_CODING_AGENT_DIR`, -`PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are -set in backpass's environment. When roots overlap, backpass scans every applicable layout -and reads each JSONL file once. +also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and +`BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots +overlap, backpass scans every applicable layout and reads each JSONL file once. 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 diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index c60cdad8..70b877a8 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -25,10 +25,10 @@ import { * the root session. BB's Pi bridge writes the same JSONL shape directly under * `/pi-bridge-sessions/`. * - * 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. + * 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"; diff --git a/src/discovery/index.js b/src/discovery/index.js index 0a89d0f5..e55c508a 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -42,8 +42,9 @@ export function getAdapter(harness) { * 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. + * 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. diff --git a/src/gap-ledger.js b/src/gap-ledger.js index 72e97557..d38ca9f1 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -35,10 +35,14 @@ import { corroborationIdentityOf } from "./transcript.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 diff --git a/src/interaction.js b/src/interaction.js index 807ebd44..3fd28e9d 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -7,8 +7,9 @@ * 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`, 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). + * 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,7 +58,9 @@ 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; From 8afb78628143fb4db1b44ef72364cfe40cff4e85 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 1 Oct 2026 17:25:03 -0500 Subject: [PATCH 10/10] feat(discovery): make OMP session collection opt-in --- README.md | 41 ++++++++++++++++++++++++----------- src/config.js | 5 +++++ src/discovery/adapters/pi.js | 19 ++++++++++------ src/discovery/hosts.js | 10 ++++----- src/discovery/index.js | 1 + src/discovery/remote/probe.js | 10 ++++----- test/adapters.test.js | 19 +++++++++++++++- test/analyze-reuse.test.js | 6 ++++- test/config.test.js | 9 ++++++++ test/interaction.test.js | 13 ++++++----- test/remote-discovery.test.js | 8 ++++++- 11 files changed, 103 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a4bad21a..e6ca82e9 100644 --- a/README.md +++ b/README.md @@ -217,26 +217,40 @@ 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` | +| 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 | +| **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 environment: if you reach that profile through an alias that only prefixes `claude`, set it for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it). -Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under -`~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It -also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and -`BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots -overlap, backpass scans every applicable layout and reads each JSONL file once. +Pi collection covers standalone sessions under `~/.pi/agent/sessions/` and BB-managed Pi +sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR`, +`PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are +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 @@ -817,6 +831,7 @@ CLI flags on top: "since": "30d", "worktreeGlobs": [], "cloneRoots": [], + "includeOmp": false, "minUserTurns": 2 }, "jobs": 4 diff --git a/src/config.js b/src/config.js index 316997ab..28af46ba 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 70b877a8..8978c5c1 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -17,7 +17,8 @@ import { /** * Pi writes standalone sessions under * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the - * same JSONL shape under `~/.omp/agent/sessions/` and honors `PI_CODING_AGENT_DIR`, but + * 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 @@ -56,12 +57,14 @@ function realpathOrResolve(value) { } } -function storeSpecs() { +function storeSpecs(config) { const specs = [ { path: storeRoot(), direct: false, nested: true }, - { path: home(".omp", "agent", "sessions"), 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); @@ -85,14 +88,16 @@ 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) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), diff --git a/src/discovery/hosts.js b/src/discovery/hosts.js index 7322a524..8fd53786 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 e55c508a..b078e9ce 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -154,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)); diff --git a/src/discovery/remote/probe.js b/src/discovery/remote/probe.js index 145460bd..6aa41c51 100644 --- a/src/discovery/remote/probe.js +++ b/src/discovery/remote/probe.js @@ -104,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 = []; @@ -127,7 +127,7 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } - const candidates = 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; @@ -162,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 = []; @@ -181,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/test/adapters.test.js b/test/adapters.test.js index 2b4fa11e..a226b63b 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -360,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(), [ diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 6acdd90d..bfa99145 100644 --- a/test/analyze-reuse.test.js +++ b/test/analyze-reuse.test.js @@ -334,6 +334,10 @@ test("OMP analysis persists parent observer identity and fold restores it for le 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); @@ -345,7 +349,7 @@ test("OMP analysis persists parent observer identity and fold restores it for le process.env.USERPROFILE = home; try { const repo = resolveRepo(dir); - const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all" } }); + 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 }; diff --git a/test/config.test.js b/test/config.test.js index d687d3cd..f7802a2c 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/interaction.test.js b/test/interaction.test.js index 226941c1..465ea1c9 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -805,10 +805,10 @@ test("OMP subagents share their parent identity and refresh cached relations", a const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + 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()) { + for (const candidate of pi.enumerate({ config })) { const descriptor = pi.classify(candidate); delete descriptor.parentSessionId; delete descriptor.parentSessionPath; @@ -839,6 +839,9 @@ test("OMP subagents share their parent identity and refresh cached relations", a 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; @@ -861,7 +864,7 @@ test("Pi child cache is invalidated when its parent session appears", async () = const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + 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: [] }; @@ -910,7 +913,7 @@ test("nested OMP subagents share the root identity once the root session appears const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + 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: [] }; @@ -997,7 +1000,7 @@ test("OMP subagents running in another repo cwd still share the root identity", const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + 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 }); diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index fd509e0e..2ea5c9bb 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -68,11 +68,17 @@ test("remote OMP subagents keep their parent's corroboration identity", async () for (const key of piEnv) delete process.env[key]; let result; try { - result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + 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];