diff --git a/.gitignore b/.gitignore index 090be83..f22397f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .DS_Store *.log package-lock.json +graft/.cache/ diff --git a/README.md b/README.md index 799a091..22e591b 100644 --- a/README.md +++ b/README.md @@ -217,15 +217,15 @@ It cannot be combined with backpass reads the local transcript stores of seven harnesses directly. No API, no upload. -| Harness | Store | Repo tie | -| -------------- | ---------------------------------------------- | --------------------------------------------------- | -| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | -| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | -| **pi** | standalone and BB-managed Pi JSONL stores | session-header `cwd` | -| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | -| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | -| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | -| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | +| Harness | Store | Repo tie | +| -------------- | ----------------------------------------------- | --------------------------------------------------- | +| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | +| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | +| **pi** | standalone, OMP, and BB-managed Pi JSONL stores | session-header `cwd` | +| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | +| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | +| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | +| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | Claude collection covers `$CLAUDE_CONFIG_DIR/projects` alongside the default store, so a relocated config dir does not hide its sessions. The variable is read from backpass's own @@ -238,6 +238,29 @@ sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR` set in backpass's environment. When roots overlap, backpass scans every applicable layout and reads each JSONL file once. +OMP (Oh My Pi) collection is **off by default**. Set `discovery.includeOmp` to `true` in +`.backpassrc.json` or your [personal config](#configuration) to also read +`~/.omp/agent/sessions/` through the Pi adapter: + +```json +{ "discovery": { "includeOmp": true } } +``` + +Then run `backpass scan --harness pi` (or a normal `backpass` run). The setting also +applies to configured SSH hosts. For `--scope user`, set it in `user.discovery` and +include `"pi"` in `user.discovery.harnesses`; user scope otherwise collects only Claude +and Codex. Explicit Pi store environment overrides still work without this setting; +it controls only the additional default OMP store, not the harness backpass invokes. + +OMP nests subagent JSONL files below each parent session, and a subagent's own subagents +one level further down. Backpass analyzes each file separately and uses its native +sightings for nested-memory ownership, but counts the root session as their shared +corroborating observer; a session and its subagents cannot count as independent sessions, +including across synced SSH stores. Matching descendant copies link their families +without merging distinct roots on the same host that share a native ID. Nested +descendants are non-interactive even when ancestor headers are unavailable; root +provenance is linked only when those headers can be read. + 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 +312,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 @@ -405,8 +428,9 @@ consolidation call sees the full open gap set and merges entries that describe t mistake. That second judgment is what lets two sightings of a brand-new gap in the same run's parallel fan-out corroborate. A failed consolidation call degrades the run to lexical identity and says so; it never aborts. All sightings cluster before their domain -is decided. Each sighting votes `project` or `orchestration`; only a majority-orchestration -cluster is excluded from synthesis, while a tie stays eligible. Mixed clusters are always +is decided. Each corroborating observer contributes one `project` or `orchestration` vote; +related sightings share a vote, with any project sighting making it project. Only a +majority-orchestration cluster is excluded from synthesis, while a tie stays eligible. Mixed clusters are always reported with their orchestration count, even below the evidence floor. A corroborated majority-excluded cluster, including a pure-orchestration cluster, remains clearly labeled as a report-only diagnostic rather than becoming an instruction in the project's memory @@ -812,6 +836,7 @@ CLI flags on top: "since": "30d", "worktreeGlobs": [], "cloneRoots": [], + "includeOmp": false, "minUserTurns": 2 }, "jobs": 4 diff --git a/src/acpx.js b/src/acpx.js index 5019733..9102e1f 100644 --- a/src/acpx.js +++ b/src/acpx.js @@ -906,8 +906,9 @@ function recoverPiUsage({ promptFile, cwd, startedAt }) { .filter((c) => c.mtimeMs >= since) .sort((a, b) => b.mtimeMs - a.mtimeMs); + const scanContext = piStore.createScanContext(); for (const candidate of candidates) { - const descriptor = piStore.classify(candidate); + const descriptor = piStore.classify(candidate, { scanContext }); if (!descriptor || !wanted.has(descriptor.cwd)) continue; const entries = readJsonl(candidate.path); const firstUser = entries.find((e) => e.type === "message" && e.message?.role === "user"); diff --git a/src/analyze.js b/src/analyze.js index a45be6f..7bbcf78 100644 --- a/src/analyze.js +++ b/src/analyze.js @@ -13,7 +13,7 @@ import { renderOpenGapIndex } from "./gap-ledger.js"; import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js"; import { emitProgress } from "./progress.js"; import { UserError, color, info, warn } from "./logger.js"; -import { transcriptIdentity } from "./transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "./transcript.js"; /** * Stage 1 of the pipeline (design section 3): one cheap model call per transcript, @@ -410,6 +410,10 @@ export async function analyzeTranscripts({ harness: transcript.harness, id: transcript.id, identity: transcriptIdentity(transcript), + parentSessionId: transcript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(transcript), + corroborationNativeId: transcript.corroborationNativeId || null, + corroborationStartedAt: transcript.corroborationStartedAt ?? null, path: transcript.path, mtimeMs: transcript.mtimeMs, bytes: transcript.bytes, diff --git a/src/commands/propose.js b/src/commands/propose.js index 9d59b56..8923176 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -1,6 +1,12 @@ import { consolidateGapLedger } from "../consolidate.js"; import { foldEvidence } from "../fold.js"; -import { ledgerGapObservations, pruneGapLedger, recordGapObservations } from "../gap-ledger.js"; +import { + ledgerGapObservations, + normalizeGapLedgerSessions, + pruneGapLedger, + recordGapObservations, + resolveGapObservationObserver, +} from "../gap-ledger.js"; import { synthesizeProposal } from "../synthesize.js"; import { ProposalViolation } from "../proposal.js"; import { formatCorpusMix, INTERACTIVE, NON_INTERACTIVE } from "../interaction.js"; @@ -13,7 +19,7 @@ import { printUsage } from "./usage.js"; import { closeRemoteDiscovery, discoverForRun } from "./scan.js"; import { capTranscripts } from "../sample.js"; import { isEvidenceFresh } from "../state.js"; -import { transcriptIdentity } from "../transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "../transcript.js"; import { pruneHostCache } from "../discovery/cache.js"; /** @@ -29,11 +35,12 @@ import { pruneHostCache } from "../discovery/cache.js"; * cap remain on disk. Folding those records would inflate `analyzedSessions` beyond the * sampled corpus or score positional instruction aliases against an index they never saw. * Legacy records stay excluded until ordinary discovery and analysis backfill them. + * Current discovery's corroboration fields are overlaid on admitted records, so a + * subagent analyzed before its parent appeared still folds under the parent's identity. */ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], transcripts = [], { route = null } = {}) { const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config; const selectedByIdentity = new Map(transcripts.map((transcript) => [transcriptIdentity(transcript), transcript])); - const selected = new Set(selectedByIdentity.keys()); const evidence = state.listEvidence(); const identitiesByLegacyId = new Map(); for (const record of evidence) { @@ -42,26 +49,46 @@ 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(); + const selectedObserversByNative = new Map(); + const legacyIds = new Set(); for (const transcript of transcripts) { + selectedGapSessions.add(corroborationIdentityOf(transcript)); + const nativeIdentity = transcript.identity || transcript.id; + if (!selectedObserversByNative.has(nativeIdentity)) selectedObserversByNative.set(nativeIdentity, new Set()); + selectedObserversByNative.get(nativeIdentity).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); + if (!selectedObserversByNative.has(transcript.id)) selectedObserversByNative.set(transcript.id, new Set()); + selectedObserversByNative.get(transcript.id).add(corroborationIdentityOf(transcript)); } } - const relevant = evidence.filter((e) => { - const currentTranscript = selectedByIdentity.get(transcriptIdentity(e.transcript)); - return ( - e.memoryPath === memoryFile.path && - e.memoryHash === memoryHash && - (e.transcript?.interaction === INTERACTIVE || e.transcript?.interaction === NON_INTERACTIVE) && - currentTranscript && - isEvidenceFresh(e, currentTranscript, memoryHash) - ); - }); + const relevant = []; + for (const record of evidence) { + const currentTranscript = selectedByIdentity.get(transcriptIdentity(record.transcript)); + if ( + record.memoryPath !== memoryFile.path || + record.memoryHash !== memoryHash || + (record.transcript?.interaction !== INTERACTIVE && record.transcript?.interaction !== NON_INTERACTIVE) || + !currentTranscript || + !isEvidenceFresh(record, currentTranscript, memoryHash) + ) { + continue; + } + const transcript = { + ...record.transcript, + parentSessionId: currentTranscript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(currentTranscript), + corroborationNativeId: currentTranscript.corroborationNativeId || null, + corroborationStartedAt: currentTranscript.corroborationStartedAt ?? null, + }; + relevant.push({ ...record, transcript }); + } const ledger = state.readGapLedger(); - recordGapObservations(ledger, relevant, { skills }); + normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); + recordGapObservations(ledger, relevant, { skills, legacyIds, transcripts }); // 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 @@ -78,9 +105,16 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans pruneGapLedger(ledger, { memoryFile, memoryPath: memoryFile.path, skills, maxAge: gapLedgerMaxAge }); state.writeGapLedger(ledger); - const gapObservations = ledgerGapObservations(ledger, memoryFile.path, skills).filter((observation) => - selectedGapSessions.has(observation.sessionId), - ); + const gapObservations = ledgerGapObservations(ledger, memoryFile.path, skills) + .map((observation) => ({ + ...observation, + sessionId: resolveGapObservationObserver(observation, selectedObserversByNative) ?? observation.sessionId, + })) + .filter((observation) => { + if (!selectedGapSessions.has(observation.sessionId)) return false; + const observers = selectedObserversByNative.get(observation.sourceSessionId); + return !observers || (observers.size === 1 && observers.has(observation.sessionId)); + }); const summary = foldEvidence(relevant, { minGapEvidence, minGapProjects: ctx.scope?.kind === "user" ? ctx.config.minGapProjects || 1 : 0, diff --git a/src/config.js b/src/config.js index 316997a..28af46b 100644 --- a/src/config.js +++ b/src/config.js @@ -119,6 +119,8 @@ export const DEFAULT_CONFIG = { * is refused by name. Absolute paths; `~` is expanded. */ opencodeStores: [], + /** Include Oh My Pi's default session store in Pi discovery; opt-in only. */ + includeOmp: false, minUserTurns: 2, includeCursorIde: false, }, @@ -467,6 +469,9 @@ function validate(config, { kind = "project", repoRoot = null } = {}) { } config.discovery.harnesses = config.discovery.harnesses.filter((h) => known.has(h)); parseSince(config.discovery.since); + if (typeof config.discovery.includeOmp !== "boolean") { + throw new UserError("config.discovery.includeOmp must be a boolean"); + } if (!Array.isArray(config.discovery.cloneRoots) || config.discovery.cloneRoots.some((p) => typeof p !== "string")) { throw new UserError("config.discovery.cloneRoots must be an array of paths"); } diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 82e5e1f..afda018 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -16,15 +16,26 @@ import { /** * Pi writes standalone sessions under - * `~/.pi/agent/sessions//_.jsonl`. BB's Pi bridge writes the - * same JSONL shape directly under `/pi-bridge-sessions/`. + * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the + * same JSONL shape under `~/.omp/agent/sessions/` (opt-in via `discovery.includeOmp`) + * and honors `PI_CODING_AGENT_DIR`, but + * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` + * entry is line 2 there. omp also writes subagent transcripts one level deeper, at + * `//.jsonl`, and their own subagents at + * `///..jsonl`; every descendant is related to + * the root session. BB's Pi bridge writes the same JSONL shape directly under + * `/pi-bridge-sessions/`. * - * Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in - * order, so a linear read is faithful. `model_change` / `thinking_level_change` records - * give the model actually used. No remote is recorded - dead worktrees reach tier 3 only. + * Entries form a parent/child tree but arrive in order, so a linear read is faithful. + * `model_change` / `thinking_level_change` records give the model actually used + * (`modelId` on pi, `model` on omp). No remote is recorded - dead worktrees reach tier 3 + * only. */ export const name = "pi"; +export const cacheVersion = 5; + +const SUBAGENT_DEPTH = 2; export function storeRoot() { return home(".pi", "agent", "sessions"); @@ -46,11 +57,14 @@ function realpathOrResolve(value) { } } -function storeSpecs() { +function storeSpecs(config) { const specs = [ { path: storeRoot(), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; + if (config?.discovery?.includeOmp === true) { + specs.push({ path: home(".omp", "agent", "sessions"), direct: false, nested: true }); + } const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); if (piAgentDir) specs.push({ path: path.join(piAgentDir, "sessions"), direct: false, nested: true }); const piSessionDir = expandEnvPath(process.env.PI_CODING_AGENT_SESSION_DIR); @@ -74,17 +88,19 @@ function storeSpecs() { return [...unique.values()]; } -export function storeRoots() { - return storeSpecs().map((spec) => spec.path); +/** @param {{ discovery?: { includeOmp?: boolean } }} [config] */ +export function storeRoots(config) { + return storeSpecs(config).map((spec) => spec.path); } -export function enumerate() { +/** @param {{ config?: { discovery?: { includeOmp?: boolean } }, cutoffMs?: number | null, repo?: object }} [options] */ +export function enumerate({ config } = {}) { const out = []; const seen = new Set(); - for (const spec of storeSpecs()) { + for (const spec of storeSpecs(config)) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested ? listDirs(spec.path).flatMap((dir) => listFiles(dir, ".jsonl")) : []), + ...(spec.nested ? listDirs(spec.path).flatMap((dir) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -92,25 +108,111 @@ export function enumerate() { seen.add(key); const stat = statOrNull(file); if (!stat) continue; - out.push({ key, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size }); + out.push({ + key, + path: file, + mtimeMs: stat.mtimeMs, + bytes: stat.size, + subagent: spec.nested && path.relative(spec.path, file).split(path.sep).length > 2, + }); } } return out; } -export function classify(candidate) { - const [first] = readHeadLines(candidate.path, 1); - const entry = first && parseJsonLine(first); +function sessionFiles(dir, depth) { + return [ + ...listFiles(dir, ".jsonl"), + ...(depth > 0 ? listDirs(dir).flatMap((sub) => sessionFiles(sub, depth - 1)) : []), + ]; +} + +export function createScanContext() { + return { parentHeaders: new Map() }; +} + +function readSessionHeader(file) { + const [firstLine, secondLine] = readHeadLines(file, 2); + const first = parseJsonLine(firstLine); + return first?.type === "session" ? first : first?.type === "title" ? parseJsonLine(secondLine) : null; +} + +function readParentSession(parentPath, scanContext) { + const cache = scanContext?.parentHeaders; + if (cache?.has(parentPath)) return cache.get(parentPath); + const stat = statOrNull(parentPath); + const entry = stat?.isFile() ? readSessionHeader(parentPath) : null; + const result = + entry?.type === "session" + ? { + entry, + stat, + fingerprint: JSON.stringify([ + stat.dev, + stat.ino, + stat.mtimeMs, + stat.ctimeMs, + stat.size, + entry.id ?? null, + entry.cwd ?? null, + entry.timestamp ?? null, + ]), + } + : null; + cache?.set(parentPath, result); + return result; +} + +function parentSessionPathFor(candidatePath) { + const sessionDir = path.dirname(candidatePath); + return path.join(path.dirname(sessionDir), `${path.basename(sessionDir)}.jsonl`); +} + +function ancestorSessionPaths(candidatePath) { + const out = []; + let current = candidatePath; + for (let depth = 0; depth < SUBAGENT_DEPTH; depth += 1) { + current = parentSessionPathFor(current); + out.push(current); + } + return out; +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function cacheDependency(candidate, options = {}) { + return JSON.stringify( + ancestorSessionPaths(candidate.path).map( + (ancestorPath) => readParentSession(ancestorPath, options.scanContext)?.fingerprint ?? null, + ), + ); +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function classify(candidate, options = {}) { + const { scanContext } = options; + const entry = readSessionHeader(candidate.path); if (!entry || entry.type !== "session" || !entry.cwd) return null; - return { + + const descriptor = { id: entry.id || path.basename(candidate.path, ".jsonl"), cwd: entry.cwd, gitBranch: null, remotes: [], startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, model: null, - interactionSignals: emptyInteractionSignals(), + interactionSignals: candidate.subagent ? { source: "subagent" } : emptyInteractionSignals(), }; + + for (const ancestorPath of ancestorSessionPaths(candidate.path)) { + const ancestorInfo = readParentSession(ancestorPath, scanContext); + const ancestor = ancestorInfo?.entry; + if (!ancestor) continue; + descriptor.parentSessionId = ancestor.id || path.basename(ancestorPath, ".jsonl"); + descriptor.parentSessionPath = ancestorPath; + descriptor.parentSessionStartedAt = ancestor.timestamp ? Date.parse(ancestor.timestamp) : ancestorInfo.stat.mtimeMs; + } + + return descriptor; } export function read(ref) { @@ -120,7 +222,7 @@ export function read(ref) { for (const entry of entries) { if (entry.type === "model_change") { - model = entry.modelId || model; + model = entry.modelId || entry.model || model; continue; } if (entry.type !== "message" || !entry.message) continue; diff --git a/src/discovery/hosts.js b/src/discovery/hosts.js index 7322a52..8fd5378 100644 --- a/src/discovery/hosts.js +++ b/src/discovery/hosts.js @@ -216,10 +216,10 @@ async function locate(entry, controlPath) { /** * Discover on every configured host, fail-soft per host. * - * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string }} options + * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string, includeOmp?: boolean }} options * @returns {Promise} one result per host, in configured order */ -export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) { +export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath, includeOmp = false }) { const results = []; for (const entry of hosts) { const result = emptyHostResult(entry); @@ -243,7 +243,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) result.error = `failed to start ssh control master: ${masterFailure.message}`; } else { result.master = masterCall.master; - await collectOneHost(entry, { harnesses, cutoffMs }, result); + await collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result); } } catch (err) { if (err instanceof UserError) { @@ -272,7 +272,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) return results; } -async function collectOneHost(entry, { harnesses, cutoffMs }, result) { +async function collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result) { const located = await locate(entry, result.master.controlPath); if (located.failure) { result.error = located.failure.message; @@ -318,7 +318,7 @@ async function collectOneHost(entry, { harnesses, cutoffMs }, result) { } const program = buildProbeProgram( - { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs }, + { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs, includeOmp }, { env: entry.env, }, diff --git a/src/discovery/index.js b/src/discovery/index.js index 87b6269..b520e6d 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -39,9 +39,11 @@ export function getAdapter(harness) { * Discovery (design section 2). * * For file-backed stores the expensive step is reading each transcript's header, so - * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. - * Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on - * the machine this was designed against. + * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. An + * adapter may also export `cacheVersion` (bumped when its classification semantics + * change) and `cacheDependency` (a fingerprint of other files a descriptor reads, such + * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Unchanged + * entries avoid reclassification, but dependency fingerprints are checked per candidate. * * SQLite-backed stores (opencode, hermes, cursor IDE) query session metadata directly, * so they skip the file-header cache entirely. @@ -151,6 +153,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)); @@ -254,6 +257,19 @@ function remoteTranscripts(result, entry, { scope, repo, config, strict, identit function dropCrossHostDuplicates(transcripts) { const seen = new Map(); const drops = new Map(); + const observerAliases = new Map(); + const observerCorrespondences = new Map(); + const canonicalObserver = (identity) => { + while (observerAliases.has(identity)) identity = observerAliases.get(identity); + return identity; + }; + const aliasObserver = (observer, retainedObserver) => { + const canonical = canonicalObserver(observer); + const retained = canonicalObserver(retainedObserver); + if (canonical !== retained) { + observerAliases.set(canonical, retained); + } + }; let write = 0; for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.nativeId}`; @@ -261,13 +277,49 @@ function dropCrossHostDuplicates(transcripts) { if (previous && previous.host !== (transcript.host || null)) { const host = transcript.host || "local"; drops.set(host, (drops.get(host) || 0) + 1); + if ( + (transcript.parentSessionId || + (transcript.harness === "pi" && transcript.interactionSignals.source === "subagent")) && + (previous.parentSessionId || (previous.harness === "pi" && previous.interactionSignals.source === "subagent")) + ) { + aliasObserver(transcript.corroborationIdentity, previous.corroborationIdentity); + const observerKey = `${transcript.harness}\n${transcript.corroborationNativeId}`; + const retainedObserverKey = `${previous.harness}\n${previous.corroborationNativeId}`; + if (!observerCorrespondences.has(observerKey)) observerCorrespondences.set(observerKey, previous); + if (!observerCorrespondences.has(retainedObserverKey)) + observerCorrespondences.set(retainedObserverKey, previous); + } continue; } - if (!previous) seen.set(key, { host: transcript.host || null }); + if (!previous) seen.set(key, transcript); transcripts[write] = transcript; write += 1; } transcripts.length = write; + const observers = new Map(observerCorrespondences); + for (const transcript of transcripts) { + const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; + const previous = observers.get(key); + if ( + !previous || + (!observerCorrespondences.has(key) && + previous.host === (transcript.host || null) && + !previous.parentSessionId && + transcript.parentSessionId) + ) { + observers.set(key, transcript); + } + } + for (const transcript of transcripts) { + const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; + const previous = observers.get(key); + if (previous.host !== (transcript.host || null) && !observerAliases.has(transcript.corroborationIdentity)) { + aliasObserver(transcript.corroborationIdentity, previous.corroborationIdentity); + } + } + for (const transcript of transcripts) { + transcript.corroborationIdentity = canonicalObserver(transcript.corroborationIdentity); + } return drops; } @@ -316,8 +368,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 +388,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 +474,21 @@ function toTranscript(adapter, row, association, id, { host = null, remote = nul remote, }; transcript.identity = transcriptIdentity(transcript); + if (row.parentSessionId && row.parentSessionPath) { + transcript.parentSessionId = row.parentSessionId; + transcript.corroborationIdentity = transcriptIdentity({ + ...transcript, + identity: null, + nativeId: row.parentSessionId, + path: row.parentSessionPath, + }); + transcript.corroborationNativeId = row.parentSessionId; + transcript.corroborationStartedAt = row.parentSessionStartedAt ?? transcript.startedAt; + } else { + transcript.corroborationIdentity = transcript.identity; + transcript.corroborationNativeId = id; + transcript.corroborationStartedAt = transcript.startedAt; + } transcript.interaction = classifyInteraction(transcript); return transcript; } diff --git a/src/discovery/remote/probe.js b/src/discovery/remote/probe.js index 1d55f98..6aa41c5 100644 --- a/src/discovery/remote/probe.js +++ b/src/discovery/remote/probe.js @@ -89,6 +89,9 @@ async function descriptorFrom(adapter, row, id) { remotes: Array.isArray(row.remotes) ? row.remotes : [], title: row.title || null, startedAt: row.startedAt || null, + parentSessionId: row.parentSessionId, + parentSessionPath: row.parentSessionPath, + parentSessionStartedAt: row.parentSessionStartedAt, mtimeMs: row.mtimeMs || 0, bytes: row.bytes || 0, contentSignature, @@ -101,7 +104,7 @@ async function descriptorFrom(adapter, row, id) { }; } -async function discoverHarness(adapter, { cutoffMs }) { +async function discoverHarness(adapter, { cutoffMs, includeOmp }) { const stats = { scanned: 0, classified: 0, self: 0, error: null }; const out = []; const warnings = []; @@ -124,10 +127,12 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } - for (const candidate of adapter.enumerate({ cutoffMs })) { + const candidates = adapter.enumerate({ cutoffMs, config: { discovery: { includeOmp } } }); + const scanContext = adapter.createScanContext?.(); + for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; stats.scanned += 1; - const classified = adapter.classify(candidate); + const classified = adapter.classify(candidate, { scanContext }); if (!classified) continue; stats.classified += 1; const merged = { ...candidate, ...classified }; @@ -157,8 +162,8 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } -/** @param {{ harnesses?: string[], cutoffMs?: number | null }} request */ -export async function discover({ harnesses = [], cutoffMs = null } = {}) { +/** @param {{ harnesses?: string[], cutoffMs?: number | null, includeOmp?: boolean }} request */ +export async function discover({ harnesses = [], cutoffMs = null, includeOmp = false } = {}) { const harnessStats = Object.create(null); const descriptors = []; const warnings = []; @@ -176,7 +181,7 @@ export async function discover({ harnesses = [], cutoffMs = null } = {}) { continue; } try { - const result = await discoverHarness(adapter, { cutoffMs }); + const result = await discoverHarness(adapter, { cutoffMs, includeOmp }); harnessStats[harness] = result.stats; descriptors.push(...result.descriptors); warnings.push(...result.warnings); diff --git a/src/fold.js b/src/fold.js index 768c282..841fb9d 100644 --- a/src/fold.js +++ b/src/fold.js @@ -10,8 +10,10 @@ import { disambiguateSourceLabels, gapSource, normalizeSourceLabel, + resolveGapObservationObserver, } 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 @@ -103,7 +105,21 @@ export function foldEvidence( // without the project requirement, so a project-scoped run still has an allowlist. const sources = new Set(); const sourceProjects = {}; - const persistedObservations = gapObservations ?? []; + const observersByNative = new Map(); + for (const record of usable) { + const nativeIdentity = record.transcript.identity || record.transcript.id; + if (!observersByNative.has(nativeIdentity)) observersByNative.set(nativeIdentity, new Set()); + observersByNative.get(nativeIdentity).add(corroborationIdentityOf(record.transcript)); + } + const persistedObservations = (gapObservations ?? []) + .map((observation) => { + const sessionId = resolveGapObservationObserver(observation, observersByNative); + return sessionId && sessionId !== observation.sessionId ? { ...observation, sessionId } : observation; + }) + .filter((observation) => { + const observers = observersByNative.get(observation?.sourceSessionId); + return !observers || (observers.size === 1 && observers.has(observation?.sessionId)); + }); const issuedSources = disambiguateSourceLabels([ ...usable.map((record) => ({ source: gapSource(record.transcript), @@ -111,12 +127,14 @@ export function foldEvidence( })), ...persistedObservations.map((observation) => ({ source: observation?.source, - identity: observation?.sessionId, + identity: observation?.sourceSessionId || observation?.sessionId, })), ]); 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 +144,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 +155,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 +179,9 @@ export function foldEvidence( quote: gap.quote, recurrenceRisk: gap.recurrenceRisk, source, - sessionId: record.transcript.identity || record.transcript.id, + sessionId: corroborationIdentity, + sourceSessionId: sessionIdentity, + sightingIds: [sessionIdentity], domain: gap.domain === "orchestration" ? "orchestration" : "project", project: record.transcript.project || null, projectRoot: record.transcript.projectRoot || null, @@ -266,7 +285,12 @@ export function foldEvidence( const owners = route ? gapClusters.map((cluster, index) => - allDecided[index].failedTriggerSkill ? null : route.ownerOf([...cluster.sessions]), + allDecided[index].failedTriggerSkill + ? null + : gapOwnerOf( + cluster.items.filter((item) => !item.projectCovered), + route, + ), ) : null; const routedGaps = []; @@ -338,6 +362,10 @@ export function foldEvidence( instructions: instructionRows, sources: [...sources], sourceProjects, + sourceObservers: sourceIdentitiesOf(issuedSources, [ + ...usable.map((record) => [corroborationIdentityOf(record.transcript)]), + ...persistedObservations.map((observation) => [observation?.sessionId]), + ]), parentHarmSessions, gaps, crossSurfaceDuplicates: duplicates, @@ -348,24 +376,29 @@ export function foldEvidence( if (route.weight === null) { summary.rootOwnedGaps = gapClusters .filter((cluster, index) => owners[index] === null && cluster.sessions.size >= minGapEvidence) - .map((cluster) => cluster.items.map((item) => ({ sessionId: item.sessionId, quote: item.quote }))); + .map((cluster) => + cluster.items.flatMap((item) => item.sightingQuotes.map((quote) => ({ sessionId: item.sessionId, quote }))), + ); } summary.routedGaps = routedGaps; - summary.sourceSessions = sourceSessionsOf(issuedSources, usable, persistedObservations); + summary.sourceSessions = sourceIdentitiesOf(issuedSources, [ + ...usable.map((record) => [record.transcript.identity || record.transcript.id]), + ...persistedObservations.map((observation) => + observation?.unattributedSightings ? [] : [observation?.sourceSessionId || observation?.sessionId], + ), + ]); } return summary; } -/** Which session each issued source label names, for the routing gate in `buildProposal`. */ -function sourceSessionsOf(issuedSources, usable, observations) { - const identities = [ - ...usable.map((record) => record.transcript.identity || record.transcript.id), - ...observations.map((observation) => observation?.sessionId), - ]; +function sourceIdentitiesOf(issuedSources, identities) { const out = {}; issuedSources.forEach((source, index) => { const label = normalizeSourceLabel(source); - if (label && identities[index] && !out[label]) out[label] = identities[index]; + if (!label) return; + const prior = out[label] ? [out[label]].flat() : []; + const combined = [...new Set([...prior, ...identities[index]].filter(Boolean))]; + if (combined.length) out[label] = combined.length === 1 ? combined[0] : combined; }); return out; } @@ -421,9 +454,16 @@ export function clusterGapObservations(observations, { checkProjectCoverage = fa const item = { mistake: obs.mistake, quote: obs.quote, + sightingQuotes: [ + ...new Set( + [obs.quote, ...(obs.sightingQuotes || [])].filter((quote) => typeof quote === "string" && quote.length > 0), + ), + ], recurrenceRisk: obs.recurrenceRisk, source: obs.source, sessionId: obs.sessionId, + sightingIds: [...new Set(obs.sightingIds || [])], + unattributedSightings: Boolean(obs.unattributedSightings || !obs.sightingIds?.length), domain: observationDomain(obs), project: obs.project || null, projectCovered, @@ -437,6 +477,9 @@ export function clusterGapObservations(observations, { checkProjectCoverage = fa if (observationDomain(obs) !== "orchestration") sessionItem.domain = "project"; if (projectCovered) sessionItem.projectCovered = true; for (const id of observationGapIds(obs)) sessionItem.gapIds.add(id); + sessionItem.sightingIds = [...new Set([...sessionItem.sightingIds, ...item.sightingIds])]; + sessionItem.sightingQuotes = [...new Set([...sessionItem.sightingQuotes, ...item.sightingQuotes])]; + sessionItem.unattributedSightings ||= item.unattributedSightings; } else { cluster.items.push(item); } @@ -537,22 +580,28 @@ function observationDomain(obs) { return obs?.domain === "orchestration" ? "orchestration" : "project"; } -/** - * Cluster domain is a majority of per-sighting votes, not a pre-filter. Ties (including - * 1 of 2) stay project so one inconsistent analysis call cannot kill a real recurrence. - */ +function gapOwnerOf(items, route) { + if (items.some((item) => item.unattributedSightings || !item.sightingIds.length)) return null; + return route.ownerOf(items.flatMap((item) => item.sightingIds)); +} + function representativeGapItems(items, route) { const selected = items.slice(0, 6); if (!route || items.length <= 6) return selected; - const owner = route.ownerOf(items.map((item) => item.sessionId)); - if (route.ownerOf(selected.map((item) => item.sessionId)) === owner) return selected; + const owner = gapOwnerOf(items, route); + if (gapOwnerOf(selected, route) === owner) return selected; for (const item of items.slice(6)) { const candidate = [...selected.slice(0, -1), item]; - if (route.ownerOf(candidate.map((entry) => entry.sessionId)) === owner) return candidate; + if (gapOwnerOf(candidate, route) === owner) return candidate; } return selected; } +/** + * Cluster domain is a majority of observer votes, not a pre-filter. Related sightings + * share a vote with project precedence; ties stay project so one inconsistent analysis + * call cannot kill a real recurrence. + */ function clusterDomainVote(items) { const orchestrationSightings = items.filter((item) => item.domain === "orchestration").length; const sightings = items.length; diff --git a/src/gap-ledger.js b/src/gap-ledger.js index e405c30..96bdd61 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -1,6 +1,7 @@ import { parseMemoryUnits, similarity } from "./memory.js"; import { parseSince } from "./config.js"; import { sha256 } from "./state.js"; +import { corroborationIdentityOf } from "./transcript.js"; /** * Durable gap corroboration across runs (`.backpass/gap-ledger.json`). @@ -34,10 +35,15 @@ import { sha256 } from "./state.js"; * afterward (majority orchestration withholds a cluster from proposals; a mixed * cluster stays visible). A missing domain counts as project, so evidence from * before the field existed keeps its old behavior. - * - Sessions are keyed by canonical transcript identity (with the legacy id as a fallback), - * so re-analyzing or re-sampling the same source session overwrites its observation and - * never adds a count. Persisted observations only contribute when that identity belongs - * to the current selected sample, so sessions outside the window or cap cannot skew fold. + * - Observations normally use corroboration identity (`corroborationIdentityOf`: the + * linked root session's identity for an OMP descendant, the transcript's own otherwise). + * Older per-file keys migrate only when selected native provenance and any occupied + * destination are compatible; legacy id keys additionally require unambiguous + * `legacyIds`. Incompatible history stays separately keyed, and recording uses a + * native or source-qualified key rather than overwriting it. Storage keys do not + * grant extra observer votes. Persisted observations only contribute when their + * admitted observer 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 @@ -61,8 +67,8 @@ export function emptyGapLedger() { } /** - * Fold-issued source label for one session. Evidence floors, `summary.sources`, and - * `sourceProjects` all key off this string, so it must not collapse two sessions. + * Fold-issued source label for one native transcript. `summary.sources` and + * `sourceProjects` key off this string; observer identities carry evidence floors. * Time-prefixed Codex ULIDs share an 8-character prefix when they start in the same * minute; keep the native id whole. * @@ -70,9 +76,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.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 = sessionSourceId(transcript); + return `${transcript.harness} · ${sourceId} · ${date}${host}`; } export function sessionSourceId(transcript = {}) { @@ -162,21 +170,130 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } +function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { + return [...new Set([transcript.identity, transcript.id])].filter( + (identity) => identity && identity !== sessionIdentity && (identity !== transcript.id || legacyIds.has(identity)), + ); +} + +function selectedNativeObservers(transcripts, legacyIds) { + const observers = new Map(); + for (const transcript of transcripts) { + if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; + const observer = corroborationIdentityOf(transcript); + for (const native of new Set([ + transcript.identity || transcript.id, + ...(legacyIds.has(transcript.id) ? [transcript.id] : []), + ])) { + if (!observers.has(native)) observers.set(native, new Set()); + observers.get(native).add(observer); + } + } + return observers; +} + +export function resolveGapObservationObserver(observation, observersByNative) { + if (!observation || hasUnattributedSightings(observation)) return null; + let observer = null; + for (const native of new Set([observation.sourceSessionId, ...(observation.sightingIds || [])])) { + const observers = observersByNative.get(native); + if (observers?.size !== 1) return null; + const [selected] = observers; + if (observer !== null && observer !== selected) return null; + observer = selected; + } + return observer; +} + +function destinationAcceptsObservation(observation, sessionIdentity, transcript, observersByNative) { + if (!observation) return true; + for (const native of new Set([observation.sourceSessionId, ...(observation.sightingIds || [])].filter(Boolean))) { + const observers = observersByNative.get(native); + if (observers && (observers.size !== 1 || !observers.has(sessionIdentity))) return false; + } + if (resolveGapObservationObserver(observation, observersByNative) === sessionIdentity) return true; + const nativeIdentity = transcript.identity || transcript.id; + return ( + (observation.unattributedSightings && + (observation.sourceSessionId === nativeIdentity || observation.sightingIds?.includes(nativeIdentity)) && + observersByNative.get(nativeIdentity)?.size === 1) || + (!observation.sourceSessionId && + transcript.harness !== "pi" && + !transcript.parentSessionId && + nativeIdentity === sessionIdentity && + (observation.sightingIds || []).every((native) => { + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + })) + ); +} + +function takePriorObservations(entry, sessionIdentity, aliases, transcript, destinationIdentity = sessionIdentity) { + const nativeIdentity = transcript.identity || transcript.id; + const current = entry.sessions[destinationIdentity]; + if ( + current && + !current.sourceSessionId && + transcript.harness !== "pi" && + !transcript.parentSessionId && + nativeIdentity === sessionIdentity + ) { + current.sourceSessionId = nativeIdentity; + current.sightingIds = [...new Set([...(current.sightingIds || []), nativeIdentity])]; + delete current.unattributedSightings; + } + for (const alias of aliases) { + const observation = entry.sessions[alias]; + if (!observation || observation.sourceSessionId) continue; + observation.sourceSessionId = nativeIdentity; + observation.sightingIds = [...new Set([...(observation.sightingIds || []), nativeIdentity])]; + observation.source = gapSource(transcript); + } + const priors = [entry.sessions[destinationIdentity], ...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 }; +} + +function hasUnattributedSightings(observation) { + return Boolean(observation.unattributedSightings || !observation.sourceSessionId); +} + +function sightingQuotesOf(observation) { + return [ + ...new Set( + [observation.quote, ...(observation.sightingQuotes || [])].filter( + (quote) => typeof quote === "string" && quote.length > 0, + ), + ), + ]; +} + /** - * 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. + * Record this run's sightings under the identity and provenance rules above, retaining + * the first-seen timestamp when replacing compatible history. * - * @param {{ now?: Date, skills?: unknown[] }} [options] + * @param {{ now?: Date, skills?: unknown[], legacyIds?: Set, transcripts?: object[] }} [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(); + const observersByNative = selectedNativeObservers( + options.transcripts || evidenceRecords.map((record) => record?.transcript), + legacyIds, + ); let recorded = 0; for (const record of evidenceRecords) { if (!record || record.status !== "ok" || !record.memoryPath) continue; const transcript = record.transcript || {}; - const sessionIdentity = transcript.identity || transcript.id; - if (!sessionIdentity) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + if (!(transcript.corroborationIdentity || transcript.identity || transcript.id)) continue; for (const gap of record.gaps || []) { if (!gap || !gap.proposedInstruction) continue; // A citation from the analysis turn wins over word overlap: the model saw both @@ -206,16 +323,46 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const identityPrior = entry.sessions[sessionIdentity]; - const aliasPrior = transcript.id && transcript.id !== sessionIdentity ? entry.sessions[transcript.id] : null; - const priors = [identityPrior, aliasPrior].filter(Boolean); - const firstObservedAt = priors - .map((observation) => observation.firstObservedAt || observation.observedAt) - .filter((value) => Number.isFinite(Date.parse(value))) - .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; - if (aliasPrior) delete entry.sessions[transcript.id]; - const coveredBySkill = - gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + let destinationIdentity = sessionIdentity; + let sourceQualified = false; + if ( + !destinationAcceptsObservation( + entry.sessions[destinationIdentity], + sessionIdentity, + transcript, + observersByNative, + ) + ) { + destinationIdentity = transcript.identity || transcript.id; + } + if ( + !destinationAcceptsObservation( + entry.sessions[destinationIdentity], + sessionIdentity, + transcript, + observersByNative, + ) + ) { + destinationIdentity = JSON.stringify([sessionIdentity, transcript.identity || transcript.id]); + sourceQualified = true; + } + const prior = takePriorObservations( + entry, + sessionIdentity, + (sourceQualified ? [] : sessionIdentityAliases(transcript, sessionIdentity, legacyIds)).filter( + (identity) => + identity !== destinationIdentity && + !entry.sessions[identity]?.sourceSessionId && + (entry.sessions[identity]?.sightingIds || []).every((native) => { + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + }), + ), + transcript, + destinationIdentity, + ); + const { priors, firstObservedAt } = prior; + const coveredBySkill = gap.coveredBySkill || prior.coveredBySkill; const phrasings = [ ...new Set([ ...priors.flatMap( @@ -224,18 +371,36 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { gap.proposedInstruction, ]), ]; - entry.sessions[sessionIdentity] = { + entry.sessions[destinationIdentity] = { 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), + sourceSessionId: transcript.identity || transcript.id, + sightingIds: [ + ...new Set( + [ + ...priors.flatMap((observation) => observation.sightingIds || []), + transcript.identity || transcript.id, + ].filter(Boolean), + ), + ], + ...(priors.some(hasUnattributedSightings) ? { unattributedSightings: true } : {}), mistake: gap.mistake, quote: gap.quote, + sightingQuotes: [...new Set([...priors.flatMap(sightingQuotesOf), ...sightingQuotesOf(gap)])], recurrenceRisk: gap.recurrenceRisk, phrasings, - domain: gap.domain === "orchestration" ? "orchestration" : "project", + domain: + gap.domain === "orchestration" && + !priors.some((observation) => observation.observedAt === observedAt && observation.domain !== "orchestration") + ? "orchestration" + : "project", // A failed trigger: the analysis judged an existing skill's content to cover // this mistake. Absent when no skill covers it (including all pre-existing // observations), and absence never counts as a citation. @@ -248,6 +413,98 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } +/** + * Re-key selected observations in old ledgers under the identity and provenance rules + * above; native analysis identities remain separate from corroborating observers. + */ +export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = new Set() } = {}) { + const selections = []; + const observersByNative = selectedNativeObservers(transcripts, legacyIds); + for (const transcript of transcripts) { + if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); + const nativeIdentity = transcript.identity || transcript.id; + const ordinaryNative = + transcript.harness !== "pi" && !transcript.parentSessionId && sessionIdentity === nativeIdentity; + selections.push({ transcript, sessionIdentity, aliases, ordinaryNative }); + } + + const resolvesTo = (observation, sessionIdentity) => + resolveGapObservationObserver(observation, observersByNative) === sessionIdentity; + for (const entry of Object.values(ledger.entries)) { + for (const { transcript, sessionIdentity, aliases: nativeAliases, ordinaryNative } of selections) { + const destination = entry.sessions[sessionIdentity]; + if ( + destination && + !resolvesTo(destination, sessionIdentity) && + !( + ordinaryNative && + !destination.sourceSessionId && + (destination.sightingIds || []).every((native) => { + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + }) + ) + ) + continue; + const aliases = nativeAliases.filter((identity) => { + const prior = entry.sessions[identity]; + return !prior?.sourceSessionId + ? (prior?.sightingIds || []).every((native) => { + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + }) + : resolvesTo(prior, sessionIdentity); + }); + for (const [identity, prior] of Object.entries(entry.sessions)) { + if ( + identity !== sessionIdentity && + prior.sourceSessionId === (transcript.identity || transcript.id) && + resolvesTo(prior, sessionIdentity) && + !aliases.includes(identity) + ) + aliases.push(identity); + } + const observation = entry.sessions[sessionIdentity]; + if (ordinaryNative && observation && !observation.sourceSessionId) { + observation.sourceSessionId = transcript.identity || transcript.id; + observation.sightingIds = [...new Set([...(observation.sightingIds || []), observation.sourceSessionId])]; + delete observation.unattributedSightings; + } + if (!aliases.some((identity) => entry.sessions[identity])) continue; + const { priors, firstObservedAt, coveredBySkill } = takePriorObservations( + entry, + sessionIdentity, + aliases, + transcript, + ); + const current = priors[0]; + const project = current.project || priors.find((observation) => observation.project)?.project; + const projectRoot = current.projectRoot || priors.find((observation) => observation.projectRoot)?.projectRoot; + + entry.sessions[sessionIdentity] = { + ...current, + ...(firstObservedAt ? { firstObservedAt } : {}), + sightingIds: [...new Set(priors.flatMap((observation) => observation.sightingIds || []))], + sightingQuotes: [...new Set(priors.flatMap(sightingQuotesOf))], + ...(priors.some(hasUnattributedSightings) ? { unattributedSightings: true } : {}), + phrasings: [ + ...new Set([ + entry.proposedInstruction, + ...priors.flatMap( + (observation) => observation.phrasings || [observation.proposedInstruction].filter(Boolean), + ), + ]), + ].filter(Boolean), + domain: priors.some((observation) => observation.domain !== "orchestration") ? "project" : "orchestration", + ...(coveredBySkill ? { coveredBySkill } : {}), + ...(project ? { project } : {}), + ...(projectRoot ? { projectRoot } : {}), + }; + } + } +} /** * Retire observations that no longer count: sightings first seen more than `maxAge` ago @@ -322,8 +579,12 @@ export function ledgerGapObservations(ledger, memoryPath, skills = null) { sessionId, gapIds: [entry.id, ...(entry.aliases || [])], source: obs.source, + ...(obs.sourceSessionId ? { sourceSessionId: obs.sourceSessionId } : {}), + sightingIds: obs.sightingIds || [], + unattributedSightings: hasUnattributedSightings(obs), mistake: obs.mistake, quote: obs.quote, + sightingQuotes: sightingQuotesOf(obs), recurrenceRisk: obs.recurrenceRisk, domain: obs.domain === "orchestration" ? "orchestration" : "project", ...(obs.coveredBySkill && (!skillNames || skillNames.has(obs.coveredBySkill)) @@ -389,6 +650,9 @@ export function mergeGapEntries(ledger, groups) { if (!prior.coveredBySkill && obs.coveredBySkill) prior.coveredBySkill = obs.coveredBySkill; if (!prior.project && obs.project) prior.project = obs.project; if (!prior.projectRoot && obs.projectRoot) prior.projectRoot = obs.projectRoot; + prior.sightingIds = [...new Set([...(prior.sightingIds || []), ...(obs.sightingIds || [])])]; + prior.sightingQuotes = [...new Set([...sightingQuotesOf(prior), ...sightingQuotesOf(obs)])]; + if (hasUnattributedSightings(prior) || hasUnattributedSightings(obs)) prior.unattributedSightings = true; prior.phrasings = [ ...new Set([ ...(prior.phrasings || [target.proposedInstruction]), diff --git a/src/interaction.js b/src/interaction.js index 020f1fa..0e04a89 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -6,9 +6,10 @@ * * Non-interactive is detected best-effort from per-harness metadata (codex * `originator: codex_exec` / `source: exec`, claude `entrypoint` values that start - * with `sdk`, an OpenCode child `parent_id`, Hermes cron/gateway/whatsapp if they - * ever leak past discovery) and from cwd (a `.no-mistakes` path segment - pipeline - * worktrees are one kind of non-interactive run, not their own category). + * with `sdk`, an OpenCode child `parent_id`, an OMP subagent's nested layout, + * Hermes cron/gateway/whatsapp if they ever leak past discovery) and from cwd (a + * `.no-mistakes` path segment - pipeline worktrees are one kind of non-interactive run, + * not their own category). */ export const INTERACTIVE = "interactive"; @@ -57,9 +58,13 @@ 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. + * 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 || (transcript?.harness === "pi" && signalsOf(transcript).source === "subagent")) { + return NON_INTERACTIVE; + } const stamped = transcript?.interaction; if (stamped === INTERACTIVE || stamped === NON_INTERACTIVE) return stamped; diff --git a/src/proposal.js b/src/proposal.js index 7011567..5ac6b9e 100644 --- a/src/proposal.js +++ b/src/proposal.js @@ -105,7 +105,7 @@ export class ProposalViolation extends Error { } } -function normalizeEdit(raw, index, knownSources = null) { +function normalizeEdit(raw, index, knownSources = null, sourceObservers = null) { const kind = String(raw?.kind || "").toLowerCase(); const refs = Array.isArray(raw?.changes) ? raw.changes : Array.isArray(raw?.hunks) ? raw.hunks : []; const normalizedEvidence = normalizeEvidence(raw?.evidence); @@ -124,7 +124,7 @@ function normalizeEdit(raw, index, knownSources = null) { // Corroboration is measured from the edit's normalized quotes, never from a // model-reported count. When the fold handed over this run's source labels, // only those labels count - a typed-but-never-issued source is not a session. - transcripts: countSources(normalizedEvidence, knownSources), + transcripts: countSources(normalizedEvidence, knownSources, sourceObservers), }; } @@ -139,11 +139,12 @@ function normalizeEvidence(evidence) { })); } -function countSources(evidence, known = null) { +function countSources(evidence, known = null, observers = null) { if (!Array.isArray(evidence)) return 0; - const labels = new Set(evidence.map((e) => normalizeSourceLabel(e?.source)).filter(Boolean)); - if (!known) return labels.size; - return [...labels].filter((label) => known.has(label)).length; + const labels = [...new Set(evidence.map((e) => normalizeSourceLabel(e?.source)).filter(Boolean))]; + const admitted = known ? labels.filter((label) => known.has(label)) : labels; + if (!observers) return admitted.length; + return new Set(admitted.flatMap((label) => [observers[label]].flat().filter(Boolean))).size; } /** The del-line texts of a hunk that are not carried by `lineCounts` (blank lines ignored). */ @@ -387,7 +388,7 @@ export function buildProposal(rawResult, context) { const knownSources = Array.isArray(summary?.sources) ? new Set(summary.sources.map(normalizeSourceLabel).filter(Boolean)) : null; - const edits = rawEdits.map((raw, i) => normalizeEdit(raw, i, knownSources)); + const edits = rawEdits.map((raw, i) => normalizeEdit(raw, i, knownSources, summary?.sourceObservers)); const changesById = new Map(measured.changes.map((c) => [c.id, c])); // Skill description lines are always loaded, so they sit under the same cap as the @@ -633,16 +634,26 @@ export function buildProposal(rawResult, context) { if (routing && !preservesAlwaysLoaded(edit.kind) && hunks.some((h) => h.added > 0 && h.removed === 0)) { const sessions = [ ...new Set( - edit.evidence.map((item) => summary?.sourceSessions?.[normalizeSourceLabel(item.source)]).filter(Boolean), + edit.evidence + .flatMap((item) => [summary?.sourceSessions?.[normalizeSourceLabel(item.source)]].flat()) + .filter(Boolean), ), ]; - const sightings = edit.evidence.map((item) => ({ - sessionId: summary?.sourceSessions?.[normalizeSourceLabel(item.source)], - quote: item.text, - })); - const owner = rootOwnsGap(sightings, routing.rootOwnedGaps) + const completeAttribution = edit.evidence.every((item) => { + const native = summary?.sourceSessions?.[normalizeSourceLabel(item.source)]; + return Array.isArray(native) ? native.length > 0 : Boolean(native); + }); + const sightings = edit.evidence.flatMap((item) => { + const label = normalizeSourceLabel(item.source); + const observers = summary?.sourceObservers?.[label] ?? summary?.sourceSessions?.[label]; + return [observers] + .flat() + .filter(Boolean) + .map((sessionId) => ({ sessionId, quote: item.text })); + }); + const owner = rootOwnsGap(sightings, routing.rootOwnedGaps ?? summary?.rootOwnedGaps) ? routing.rootPath - : (routing.ownerOf(sessions) ?? routing.rootPath); + : ((completeAttribution ? routing.ownerOf(sessions) : null) ?? routing.rootPath); const here = routing.weight ?? routing.rootPath; if (owner !== here) { violations.push( diff --git a/src/transcript.js b/src/transcript.js index 32dc519..d39a789 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -37,3 +37,8 @@ export function transcriptIdentity(transcript) { ) .digest("hex"); } + +/** Shared observer identity for related transcript files such as OMP subagents. */ +export function corroborationIdentityOf(transcript) { + return transcript?.corroborationIdentity || transcriptIdentity(transcript); +} diff --git a/test/adapters.test.js b/test/adapters.test.js index d95bacd..b4ccd78 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -151,6 +151,123 @@ test("pi adapter reads the session header and drops thinking blocks", () => { assert.equal(toolCall.result, "nothing to commit"); }); +test("pi adapter classifies omp sessions past the title record and reads model", () => { + const file = path.join(FIXTURES, "omp-session.jsonl"); + const descriptor = pi.classify(candidateFor(file)); + assert.equal(descriptor.id, "omp-5678"); + assert.equal(descriptor.cwd, "/repo/demo"); + + const { events, model } = pi.read({ path: file }); + assert.equal(model, "cursor/composer-2.5", "omp model_change carries model, not modelId"); + const [toolCall] = tools(events); + assert.equal(toolCall.name, "bash"); + assert.equal(toolCall.result, "nothing to commit"); +}); +test("pi adapter accepts only line one or line two after a title header", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-header-")); + const title = JSON.stringify({ type: "title", v: 1, title: "" }); + const session = JSON.stringify({ type: "session", version: 3, id: "late", cwd: "/repo/demo" }); + const other = JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }); + const afterTitle = path.join(dir, "after-title.jsonl"); + const afterOther = path.join(dir, "after-other.jsonl"); + + fs.writeFileSync(afterTitle, `${title}\n${other}\n${session}\n`); + fs.writeFileSync(afterOther, `${other}\n${session}\n`); + + assert.equal(pi.classify(candidateFor(afterTitle)), null, "line three is outside the header"); + assert.equal(pi.classify(candidateFor(afterOther)), null, "line two is a header only after a title record"); +}); + +test("pi adapter links an OMP subagent to its sibling parent session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + assert.equal(child.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); + assert.equal(pi.classify(candidateFor(parentPath)).parentSessionId, undefined); +}); + +test("pi adapter links a second-level OMP subagent to the root session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/repo/demo" }); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.id, "grandchild-native"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); + assert.equal(grandchild.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); +}); + +test("pi adapter links OMP subagents by nested path even when their cwd differs", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo/packages/api" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/worktrees/demo-isolated" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.cwd, "/repo/demo/packages/api", "association still uses the subagent's own cwd"); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.cwd, "/worktrees/demo-isolated"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); +}); + +test("pi discovery checks a missing parent path once per scan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-parent-cache-")); + const sessionDir = path.join(root, "sessions", "-repo-demo"); + const firstPath = path.join(sessionDir, "first.jsonl"); + const secondPath = path.join(sessionDir, "second.jsonl"); + writePiSession(firstPath, { id: "first", cwd: "/repo/demo" }); + writePiSession(secondPath, { id: "second", cwd: "/repo/demo" }); + const candidates = [candidateFor(firstPath), candidateFor(secondPath)]; + const missingParentPath = path.join(root, "sessions", "-repo-demo.jsonl"); + const scanContext = pi.createScanContext(); + const originalStatSync = fs.statSync; + let parentProbes = 0; + + fs.statSync = function (file, ...args) { + if (file === missingParentPath) parentProbes += 1; + return originalStatSync.call(this, file, ...args); + }; + try { + for (const candidate of candidates) pi.classify(candidate, { scanContext }); + } finally { + fs.statSync = originalStatSync; + } + + assert.ok(parentProbes <= 1, "ordinary sessions in one store should not repeat the same missing-parent lookup"); +}); +function writeOmpSession(file, { id, cwd }) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-27T00:00:00.000Z", pad: " " })}\n` + + `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd })}\n`, + ); +} + function writePiSession(file, { id, cwd }) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -190,6 +307,37 @@ function withPiStoreEnv( } } +test("pi nested layout signals subagents without readable ancestor headers", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-missing-headers-")); + const sessionDir = path.join(homeDir, ".omp", "agent", "sessions", "-repo-demo"); + const childPath = path.join(sessionDir, "root", "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, "other-root", "Missing", "Missing.Child.jsonl"); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/repo/demo" }); + fs.writeFileSync(path.join(sessionDir, "root.jsonl"), "not a session header\n"); + const piPath = path.join(homeDir, ".pi", "agent", "sessions", "-repo-demo", "ordinary.jsonl"); + const bridgeDir = path.join(homeDir, "bridge"); + const bridgePath = path.join(bridgeDir, "ordinary.jsonl"); + writePiSession(piPath, { id: "pi-native", cwd: "/repo/demo" }); + writePiSession(bridgePath, { id: "bridge-native", cwd: "/repo/demo" }); + + withPiStoreEnv({ homeDir, bridgeDir }, () => { + const candidates = pi.enumerate({ config: { discovery: { includeOmp: true } } }); + for (const file of [childPath, grandchildPath]) { + const descriptor = pi.classify(candidates.find((candidate) => candidate.path === file)); + assert.equal(descriptor.interactionSignals.source, "subagent"); + assert.equal(descriptor.parentSessionId, undefined); + assert.equal(descriptor.parentSessionPath, undefined); + assert.equal(descriptor.parentSessionStartedAt, undefined); + } + for (const file of [piPath, bridgePath]) { + const descriptor = pi.classify(candidates.find((candidate) => candidate.path === file)); + assert.deepEqual(descriptor.interactionSignals, {}); + assert.equal(descriptor.parentSessionId, undefined); + } + }); +}); + test("pi adapter enumerates standalone and BB-managed session roots without duplicates", () => { const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-home-")); const piAgentDir = path.join(fakeHome, "pi-agent"); @@ -200,6 +348,27 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl id: "standalone", cwd: "/repo/demo", }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone.jsonl"), { + id: "omp-standalone", + cwd: "/repo/demo", + }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone", "Subagent.jsonl"), { + id: "omp-subagent", + cwd: "/repo/demo", + }); + writeOmpSession( + path.join( + fakeHome, + ".omp", + "agent", + "sessions", + "-repo-demo", + "omp-standalone", + "Subagent", + "Subagent.Child.jsonl", + ), + { id: "omp-nested-subagent", cwd: "/repo/demo" }, + ); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -222,9 +391,26 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl }); withPiStoreEnv({ homeDir: fakeHome, piAgentDir, piSessionDir, bbDataDir, bridgeDir }, () => { + for (const config of [undefined, { discovery: { includeOmp: false } }]) { + assert.deepEqual( + pi + .enumerate({ config }) + .map((candidate) => path.basename(candidate.path)) + .sort(), + [ + "custom-agent.jsonl", + "custom-data.jsonl", + "custom-session.jsonl", + "default-bb.jsonl", + "direct-override.jsonl", + "standalone.jsonl", + ].sort(), + "OMP's default store is not read unless explicitly enabled", + ); + } assert.deepEqual( pi - .enumerate() + .enumerate({ config: { discovery: { includeOmp: true } } }) .map((candidate) => path.basename(candidate.path)) .sort(), [ @@ -233,8 +419,11 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "custom-session.jsonl", "default-bb.jsonl", "direct-override.jsonl", + "omp-standalone.jsonl", "standalone.jsonl", - ], + "Subagent.Child.jsonl", + "Subagent.jsonl", + ].sort(), ); }); diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 9669514..f75bd67 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,159 @@ test("old-hash leftover evidence cannot change the current fold's session count, }); }); }); + +test("OMP analysis persists parent observer identity and fold restores it for legacy evidence", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-fold-home-")); + const dir = initRepo(MEMORY); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + + const writeOmpTranscript = (file, id) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const entries = [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: dir }, + { type: "message", message: { role: "user", content: "Please build the project." } }, + { type: "message", message: { role: "assistant", content: "Ran make build as instructed." } }, + { type: "message", message: { role: "user", content: "Now run the tests too." } }, + { type: "message", message: { role: "assistant", content: "Tests pass." } }, + ]; + fs.writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + }; + + writeOmpTranscript(path.join(sessionRoot, `${parentName}.jsonl`), "parent-native"); + writeOmpTranscript(path.join(sessionRoot, parentName, "Subagent.jsonl"), "child-native"); + const disabled = runAnalyze(dir, home); + assert.equal(disabled.status, 0, disabled.output); + assert.equal(JSON.parse(disabled.stdout).transcripts, 0, "the default CLI run does not read OMP sessions"); + fs.writeFileSync(path.join(dir, ".backpassrc.json"), JSON.stringify({ discovery: { includeOmp: true } })); + + const analyzed = runAnalyze(dir, home); + assert.equal(analyzed.status, 0, analyzed.output); + assert.equal(analyzed.summary.analyzed, 2, "the real analyzer writes evidence for parent and child"); + + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + const repo = resolveRepo(dir); + const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + const state = new State(dir).ensure(); + config.state = state; + const ctx = { repo, config, scope: null, strict: false, limit: null }; + const { transcripts } = await discoverForRun(ctx); + assert.equal(transcripts.length, 2, "discovery returns both OMP transcripts"); + assert.equal(new Set(transcripts.map((transcript) => transcript.corroborationIdentity)).size, 1); + + const evidence = state.listEvidence(); + assert.equal(evidence.length, 2); + + const memoryFile = resolveMemoryFiles(dir, ["AGENTS.md", "CLAUDE.md"]).primary; + const memoryHash = evidence[0].memoryHash; + const foldCtx = { repo, config: { ...config, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, scope: null }; + + const current = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(current.gaps.length, 0, "parent and child count as one observer, below the two-session threshold"); + const currentSessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(currentSessionIds, [transcripts[0].corroborationIdentity]); + assert.ok( + evidence.every((record) => + transcripts.some( + (transcript) => + transcript.path === record.transcript.path && + transcript.corroborationIdentity === record.transcript.corroborationIdentity, + ), + ), + "analysis persists the observer identity needed by later folds", + ); + + const childEvidence = evidence.find((record) => record.transcript.path === childPath); + assert.ok(childEvidence); + for (const field of [ + "parentSessionId", + "corroborationIdentity", + "corroborationNativeId", + "corroborationStartedAt", + ]) { + delete childEvidence.transcript[field]; + } + state.writeEvidence(childEvidence.transcript, childEvidence); + + const reused = runAnalyze(dir, home); + assert.equal(reused.status, 0, reused.output); + assert.deepEqual([reused.summary.analyzed, reused.summary.cached], [0, 2]); + const reusedChild = state.listEvidence().find((record) => record.transcript.path === childPath); + assert.equal(reusedChild.transcript.parentSessionId, "parent-native"); + assert.equal(reusedChild.transcript.corroborationIdentity, transcripts[0].corroborationIdentity); + assert.equal(reusedChild.transcript.corroborationNativeId, "parent-native"); + + state.writeGapLedger({ version: 1, entries: {} }); + for (const record of evidence) { + const transcript = { ...record.transcript }; + delete transcript.parentSessionId; + delete transcript.corroborationIdentity; + delete transcript.corroborationNativeId; + delete transcript.corroborationStartedAt; + state.writeEvidence(transcript, { ...record, transcript }); + } + + const legacy = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(legacy.gaps.length, 0, "selected discovery metadata restores identity for older evidence"); + const legacySessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(legacySessionIds, [transcripts[0].corroborationIdentity]); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + } +}); + +test("OMP analysis labels missing-ancestor child and grandchild as automation without invented root provenance", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-missing-analysis-")); + const dir = initRepo(MEMORY); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const files = [ + [path.join(sessionRoot, "root", "Child.jsonl"), "child-native"], + [path.join(sessionRoot, "other-root", "Missing", "Missing.Grandchild.jsonl"), "grandchild-native"], + ]; + for (const [file, id] of files) { + 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`); + } + fs.writeFileSync(path.join(sessionRoot, "root.jsonl"), "not a session header\n"); + fs.writeFileSync(path.join(dir, ".backpassrc.json"), JSON.stringify({ discovery: { includeOmp: true } })); + const analyzed = runAnalyze(dir, home); + assert.equal(analyzed.status, 0, analyzed.output); + assert.equal(analyzed.summary.analyzed, 2); + const state = new State(dir).ensure(); + const evidence = state.listEvidence(); + assert.equal(evidence.length, 2); + for (const record of evidence) { + assert.equal(record.transcript.interaction, "non-interactive"); + assert.equal(record.transcript.parentSessionId, null); + assert.equal(record.transcript.corroborationIdentity, record.transcript.identity); + assert.equal(record.transcript.corroborationNativeId, record.transcript.id.slice("pi-".length)); + assert.equal(record.transcript.corroborationStartedAt, record.transcript.startedAt); + record.transcript.interaction = "interactive"; + state.writeEvidence(record.transcript, record); + } + const reused = runAnalyze(dir, home); + assert.equal(reused.status, 0, reused.output); + assert.deepEqual([reused.summary.analyzed, reused.summary.cached], [0, 2]); + assert.ok(state.listEvidence().every((record) => record.transcript.interaction === "non-interactive")); +}); diff --git a/test/config.test.js b/test/config.test.js index d687d3c..f7802a2 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -178,6 +178,15 @@ test("--include-cursor-ide is the only way the deferred store is scanned", () => assert.ok(config.discovery.harnesses.includes("cursor-ide")); }); +test("OMP discovery rejects non-boolean opt-ins rather than treating them as enabled", () => { + for (const includeOmp of ["true", 1, null, []]) { + assert.throws( + () => loadConfig(tempRepo({ discovery: { includeOmp } })), + /config\.discovery\.includeOmp must be a boolean/, + ); + } +}); + test("unknown harness names are dropped rather than failing the run", () => { const config = loadConfig(tempRepo({ discovery: { harnesses: ["claude", "not-a-harness"] } })); assert.deepEqual(config.discovery.harnesses, ["claude"]); diff --git a/test/fixtures/omp-session.jsonl b/test/fixtures/omp-session.jsonl new file mode 100644 index 0000000..7ee3264 --- /dev/null +++ b/test/fixtures/omp-session.jsonl @@ -0,0 +1,6 @@ +{"type":"title","v":1,"title":"","updatedAt":"2026-08-03T08:00:00.000Z","pad":" "} +{"type":"session","version":3,"id":"omp-5678","timestamp":"2026-08-03T08:00:00.000Z","cwd":"/repo/demo"} +{"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-08-03T08:00:00.100Z","model":"cursor/composer-2.5","resolvedModelIsFallback":false} +{"type":"message","id":"e1","parentId":"m1","timestamp":"2026-08-03T08:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Add the changelog entry."}]}} +{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-08-03T08:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning that must be dropped"},{"type":"text","text":"Editing CHANGELOG.md."},{"type":"toolCall","id":"tc1","name":"bash","arguments":{"command":"git status"}}]}} +{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-08-03T08:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","content":[{"type":"text","text":"nothing to commit"}]}} diff --git a/test/fold.test.js b/test/fold.test.js index 3914762..c859389 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -145,6 +145,215 @@ test("current records and legacy gap observations share one label per session", assert.deepEqual(new Set(Object.values(summary.sourceProjects)), new Set(["repo-a", "repo-b"])); }); +test("direct fold rejects a selected representative's inconsistent historical observer before clustering", () => { + const gap = "Read database documentation before writing queries."; + const current = (id, observer) => + record(id, { + transcript: { + id, + identity: id, + nativeId: id, + harness: "pi", + corroborationIdentity: observer, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + }, + negative: [{ instruction: "AG-001", quote: `current mistake ${id}`, class: "non-compliance" }], + }); + const historical = { + proposedInstruction: gap, + sessionId: "P", + sourceSessionId: "G", + sightingIds: ["P", "G"], + source: "pi · G · 2026-08-01", + quote: "historical G quote", + sightingQuotes: ["historical P quote", "historical G quote"], + }; + const independent = { + proposedInstruction: gap, + sessionId: "X", + sourceSessionId: "X", + sightingIds: ["X"], + source: "pi · X · 2026-08-01", + quote: "independent X quote", + }; + const route = { weight: null, rootPath: "AGENTS.md", ownerOf: () => null }; + for (const matchingGap of [false, true]) { + const fresh = { + proposedInstruction: gap, + sessionId: "L", + sourceSessionId: "G", + sightingIds: ["G"], + source: historical.source, + quote: "fresh G quote", + }; + const summary = foldEvidence([current("P", "P"), current("G", "L"), current("X", "X")], { + minGapEvidence: 2, + memoryFile, + route, + gapObservations: [historical, independent, ...(matchingGap ? [fresh] : [])], + }); + assert.equal(summary.sourceObservers[historical.source], "L"); + assert.equal(summary.sourceSessions[historical.source], "G"); + assert.equal(summary.totals.gapSightings, matchingGap ? 2 : 1); + assert.equal(summary.gaps.length, matchingGap ? 1 : 0); + if (matchingGap) { + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), [ + "fresh G quote", + "independent X quote", + ]); + } + assert.ok(!summary.rootOwnedGaps.flat().some((item) => item.sessionId === "P")); + } + const ambiguous = foldEvidence([current("G", "L"), current("G", "M")], { + gapObservations: [{ ...historical, sessionId: "L" }, independent], + minGapEvidence: 2, + }); + assert.equal(ambiguous.totals.gapSightings, 1); + assert.deepEqual(ambiguous.gaps, []); + + for (const representativeSelected of [false, true]) { + const summary = foldEvidence( + [current("P", "P"), current("X", "X"), ...(representativeSelected ? [current("G", "L")] : [])], + { + gapObservations: [{ ...historical, sessionId: representativeSelected ? "L" : "P" }, independent], + minGapEvidence: 2, + route: { + ...route, + ownerOf: (ids) => (ids.includes("P") ? null : "apps/api/AGENTS.md"), + }, + }, + ); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.sourceObservers[historical.source], representativeSelected ? "L" : "P"); + assert.equal(summary.sourceSessions[historical.source], "G"); + assert.deepEqual( + new Set(summary.rootOwnedGaps.flat().map((item) => item.quote)), + new Set([...historical.sightingQuotes, independent.quote]), + ); + } +}); + +test("direct fold resolves retained native aliases without admitting incompatible occupied observers", () => { + const gap = "Read database documentation before writing queries."; + const current = (id, observer) => + record(id, { + transcript: { + id, + identity: id, + nativeId: id, + harness: "pi", + corroborationIdentity: observer, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + }, + }); + const historical = { + proposedInstruction: gap, + sessionId: "P", + sourceSessionId: "G", + sightingIds: ["P", "G"], + source: "pi · G · 2026-08-01", + quote: "historical G quote", + sightingQuotes: ["historical P quote", "historical G quote"], + }; + const native = { + proposedInstruction: gap, + sessionId: "H", + sourceSessionId: "H", + sightingIds: ["H"], + source: "pi · H · 2026-08-01", + quote: "valid H quote", + domain: "orchestration", + }; + const independent = { + proposedInstruction: gap, + sessionId: "X", + sourceSessionId: "X", + sightingIds: ["X"], + source: "pi · X · 2026-08-01", + quote: "valid X quote", + }; + const duplicate = { ...native, quote: "second valid H quote", domain: "project" }; + const summary = foldEvidence([current("P", "P"), current("G", "L"), current("H", "P"), current("X", "X")], { + minGapEvidence: 2, + gapObservations: [historical, native, duplicate, independent], + route: { weight: null, rootPath: "AGENTS.md", ownerOf: () => null }, + }); + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), ["valid H quote", "valid X quote"]); + assert.equal(summary.sourceObservers[native.source], "P"); + assert.equal(summary.sourceSessions[native.source], "H"); + assert.deepEqual( + new Set( + summary.rootOwnedGaps + .flat() + .filter((item) => item.sessionId === "P") + .map((item) => item.quote), + ), + new Set(["valid H quote", "second valid H quote"]), + ); + const singleton = foldEvidence([current("H", "P")], { + minGapEvidence: 2, + gapObservations: [native, duplicate], + }); + assert.deepEqual(singleton.gaps, []); + assert.equal(singleton.totals.droppedGapSingletons, 1); + const api = foldEvidence([current("H", "P"), current("X", "X")], { + minGapEvidence: 2, + gapObservations: [native, independent], + route: { + weight: "apps/api/AGENTS.md", + rootPath: "AGENTS.md", + ownerOf: (ids) => (ids.every((id) => id === "H" || id === "X") ? "apps/api/AGENTS.md" : null), + }, + }); + assert.equal(api.gaps[0].sessions, 2); + assert.equal(api.sourceSessions[native.source], "H"); +}); + +test("direct fold resolves source-qualified collision records to one validated observer", () => { + const gap = "Read database documentation before writing queries."; + const current = (id, observer) => + record(id, { + transcript: { + id, + identity: id, + nativeId: id, + harness: "pi", + corroborationIdentity: observer, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + }, + }); + const fresh = { + proposedInstruction: gap, + sessionId: JSON.stringify(["P", "P"]), + sourceSessionId: "P", + sightingIds: ["P"], + source: "pi · P · 2026-08-01", + quote: "fresh P quote", + }; + const independent = { + proposedInstruction: gap, + sessionId: "X", + sourceSessionId: "X", + sightingIds: ["X"], + source: "pi · X · 2026-08-01", + quote: "fresh X quote", + }; + const summary = foldEvidence([current("P", "P"), current("X", "X")], { + gapObservations: [fresh, independent], + minGapEvidence: 2, + }); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.sourceObservers[fresh.source], "P"); + const singleton = foldEvidence([current("P", "P")], { + gapObservations: [fresh, { ...fresh, sessionId: "P" }], + minGapEvidence: 2, + }); + assert.deepEqual(singleton.gaps, []); + assert.equal(singleton.totals.droppedGapSingletons, 1); +}); + test("instructions with no evidence still appear - they are the removal candidates", () => { const summary = foldEvidence([record("s1", { positive: [{ instruction: "AG-001", quote: "q" }] })], { memoryFile }); @@ -532,6 +741,75 @@ 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 + 86_400_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.deepEqual(parentAndChild.sources, ["pi · parent-native · 2026-08-01", "pi · child-native · 2026-08-02"]); + assert.deepEqual(Object.values(parentAndChild.sourceObservers), ["pi-parent-session", "pi-parent-session"]); + 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, 3); + assert.equal(twoObservers.get("AG-001").harmSessions, 2); + assert.equal(twoObservers.get("AG-002").nonComplianceSessions, 2); + assert.equal(twoObservers.get("AG-001").sessions, 3); +}); test("failed-trigger citations count per skill and reach the synthesis prompt with the cluster", () => { const covered = (id, phrasing) => diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 417ed8c..25e789a 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -14,7 +14,11 @@ import { mergeGapEntries, pruneGapLedger, recordGapObservations, + normalizeGapLedgerSessions, } from "../src/gap-ledger.js"; +import { routingFor, workedPaths } from "../src/nested.js"; +import { buildProposal } from "../src/proposal.js"; +import { makeRepo, stageAndMeasure, writeIn } from "./helpers/staging.js"; const MEMORY_PATH = "AGENTS.md"; const DAY = 86_400_000; @@ -45,7 +49,7 @@ function record(id, gaps, { startedAt = Date.parse("2026-08-01T00:00:00Z"), memo /** A throwaway `.backpass/` and a ctx shaped like the one `foldForRun` reads. */ function harness(overrides = {}) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-ledger-")); + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "backpass-ledger-"))); const state = new State(root).ensure(); const ctx = { config: { state, minGapEvidence: 2, gapLedgerMaxAge: "90d", ...overrides } }; return { root, state, ctx }; @@ -113,6 +117,574 @@ test("persisted selected gap sources remain fold-issued without fresh evidence", } }); +test("selected native provenance migrates durable observers without a fresh matching gap", async () => { + for (const [oldObserver, currentObserver] of [ + ["child-observer", "ancestor-observer"], + ["old-host:observer", "canonical-host:observer"], + ]) { + for (const reanalyzed of [false, true]) { + const h = harness({ gapLedgerMaxAge: "all" }); + const native = (id, observer, gaps, memoryHash) => { + const evidence = record(id, gaps, { memoryHash }); + evidence.transcript = { + ...evidence.transcript, + harness: "pi", + nativeId: id, + corroborationIdentity: observer, + }; + evidence.key = evidenceKey(evidence.transcript, memoryHash); + return evidence; + }; + const old = native("G", oldObserver, [{ proposedInstruction: GAP, domain: "orchestration" }], "h1"); + await run(h, [old]); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const before = h.state.readGapLedger().entries[entryId].sessions[oldObserver]; + const current = native("G", currentObserver, [], "h2"); + const independent = native("X", "independent-observer", [GAP_REPHRASED], "h2"); + if (reanalyzed) h.state.writeEvidence(current.transcript.id, current); + h.state.writeEvidence(independent.transcript.id, independent); + const summary = await foldForRun(h.ctx, memoryFile(), "h2", [], [current.transcript, independent.transcript]); + assert.equal(summary.analyzedSessions, reanalyzed ? 2 : 1); + assert.equal(summary.gaps.length, 1); + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), ["quote from G", "quote from X"]); + const sessions = h.state.readGapLedger().entries[entryId].sessions; + assert.equal(sessions[oldObserver], undefined); + assert.equal(sessions[currentObserver].firstObservedAt, before.firstObservedAt); + assert.equal(sessions[currentObserver].observedAt, before.observedAt); + assert.equal(sessions[currentObserver].memoryHash, "h1"); + assert.equal(sessions[currentObserver].sourceSessionId, "G"); + assert.deepEqual(sessions[currentObserver].sightingIds, ["G"]); + assert.equal(sessions[currentObserver].domain, "orchestration"); + } + } +}); + +test("observer migration requires complete selected unambiguous native provenance", () => { + const selected = (identity, observer) => ({ + id: `pi-${identity}`, + identity, + harness: "pi", + corroborationIdentity: observer, + }); + const cases = [ + { name: "convergent", transcripts: [selected("G", "R"), selected("H", "R")], move: true }, + { name: "unselected sighting", transcripts: [selected("G", "R")], move: false }, + { name: "unselected representative", transcripts: [selected("H", "R")], move: false }, + { name: "divergent", transcripts: [selected("G", "R"), selected("H", "S")], move: false }, + { + name: "ambiguous native", + transcripts: [selected("G", "R"), selected("G", "S"), selected("H", "R")], + move: false, + }, + { + name: "unattributed", + transcripts: [selected("G", "R"), selected("H", "R")], + move: false, + unattributedSightings: true, + }, + ]; + for (const scenario of cases) { + for (const oldKey of ["C", "G"]) { + const entryId = gapEntryId(MEMORY_PATH, GAP); + const observation = { + firstObservedAt: "2026-08-01T00:00:00Z", + observedAt: "2026-08-03T00:00:00Z", + source: "pi · G · 2026-08-01", + sourceSessionId: "G", + sightingIds: ["H"], + quote: "representative quote", + sightingQuotes: ["other native quote"], + domain: "project", + ...(scenario.unattributedSightings ? { unattributedSightings: true } : {}), + }; + const ledger = { + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: { [oldKey]: structuredClone(observation) }, + }, + }, + }; + normalizeGapLedgerSessions(ledger, scenario.transcripts); + const observations = ledgerGapObservations(ledger, MEMORY_PATH); + assert.equal(observations.length, 1, scenario.name); + assert.equal(observations[0].sessionId, scenario.move ? "R" : oldKey, scenario.name); + const summary = foldEvidence([], { + minGapEvidence: 1, + gapObservations: observations.filter((observation) => observation.sessionId === "R"), + }); + assert.equal(summary.gaps.length, scenario.move ? 1 : 0, scenario.name); + if (!scenario.move) assert.deepEqual(ledger.entries[entryId].sessions[oldKey], observation); + else { + assert.equal(observations[0].sourceSessionId, "G"); + assert.deepEqual(observations[0].sightingIds, ["H"]); + assert.deepEqual(observations[0].sightingQuotes, ["representative quote", "other native quote"]); + } + } + } +}); + +test("foldForRun leaves ambiguous and incompletely selected old observers outside corroboration", async () => { + const native = (identity, observer) => ({ + id: identity, + identity, + harness: "pi", + corroborationIdentity: observer, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + interaction: "interactive", + }); + const scenarios = [ + { selected: [native("G", "R")], sightingIds: ["G", "H"] }, + { selected: [native("G", "R"), native("H", "S")], sightingIds: ["G", "H"] }, + { selected: [native("G", "R"), native("G", "S")], sightingIds: ["G"] }, + { selected: [native("G", "R")], sightingIds: ["G"], unattributedSightings: true }, + ]; + for (const scenario of scenarios) { + const h = harness({ gapLedgerMaxAge: "all" }); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const old = { + observedAt: "2026-08-01T00:00:00Z", + source: "pi · G · 2026-08-01", + sourceSessionId: "G", + sightingIds: scenario.sightingIds, + quote: "old G quote", + ...(scenario.unattributedSightings ? { unattributedSightings: true } : {}), + }; + h.state.writeGapLedger({ + version: 1, + entries: { + [entryId]: { id: entryId, memoryPath: MEMORY_PATH, proposedInstruction: GAP, sessions: { C: old } }, + }, + }); + const independent = record("X", [GAP], { memoryHash: "h2" }); + h.state.writeEvidence(independent.transcript.id, independent); + const summary = await foldForRun(h.ctx, memoryFile(), "h2", [], [...scenario.selected, independent.transcript]); + assert.equal(summary.analyzedSessions, 1); + assert.deepEqual(summary.gaps, []); + assert.deepEqual(h.state.readGapLedger().entries[entryId].sessions.C, old); + assert.equal(h.state.readGapLedger().entries[entryId].sessions.R, undefined); + } +}); + +test("a selected representative cannot grant its former selected observer to one quote", async () => { + const native = (id, observer, gaps, memoryHash) => { + const evidence = record(id, gaps, { memoryHash }); + evidence.transcript = { + ...evidence.transcript, + harness: "pi", + nativeId: id, + corroborationIdentity: observer, + }; + evidence.key = evidenceKey(evidence.transcript, memoryHash); + return evidence; + }; + for (const parentFresh of [false, true]) { + for (const matchingGap of [false, true]) { + const h = harness({ gapLedgerMaxAge: "all" }); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [native("P", "P", [GAP], "h1"), native("G", "P", [GAP], "h1")]); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const historical = structuredClone(ledger.entries[entryId].sessions.P); + assert.equal(historical.sourceSessionId, "G"); + assert.deepEqual(historical.sightingIds, ["P", "G"]); + h.state.writeGapLedger(ledger); + + const parent = native("P", "P", [], parentFresh ? "h2" : "h1"); + const representative = native("G", "L", matchingGap ? [GAP] : [], "h2"); + representative.negative = [ + { + instruction: "AG-001", + quote: "G skipped the database documentation", + class: "non-compliance", + }, + ]; + const independent = native("X", "X", [GAP], "h2"); + for (const evidence of [parent, representative, independent]) { + h.state.writeEvidence(evidence.transcript.id, evidence); + } + const route = { weight: null, rootPath: MEMORY_PATH, ownerOf: () => null }; + const summary = await foldForRun( + h.ctx, + memoryFile(), + "h2", + [], + [parent.transcript, representative.transcript, independent.transcript], + { route }, + ); + + assert.equal(summary.analyzedSessions, parentFresh ? 3 : 2); + assert.equal(summary.totals.gapSightings, matchingGap ? 2 : 1); + assert.equal(summary.gaps.length, matchingGap ? 1 : 0); + if (matchingGap) { + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), ["quote from G", "quote from X"]); + } + const quote = summary.instructions.find((row) => row.instruction === "AG-001").quotes[0]; + assert.equal(summary.sourceObservers[quote.source], "L"); + assert.equal(summary.sourceSessions[quote.source], "G"); + assert.ok(!summary.rootOwnedGaps.flat().some((item) => item.sessionId === "P")); + assert.deepEqual(h.state.readGapLedger().entries[entryId].sessions.P, historical); + + const repo = makeRepo({ [MEMORY_PATH]: "# T\n\n- Run pnpm test before pushing.\n- Keep the README current.\n" }); + const staged = stageAndMeasure({ + repo, + edit: (root) => writeIn(root, MEMORY_PATH, (text) => `${text}- ${GAP}\n`), + }); + const built = buildProposal( + { + edits: [ + { + changes: staged.measured.changes.map((change) => change.id), + kind: "add", + title: "Read the database documentation", + rationale: "Avoid re-deriving query behavior", + evidence: [{ polarity: "negative", text: quote.text, source: quote.source }], + }, + ], + }, + { + repo, + memoryFile: staged.memoryFile, + measured: staged.measured, + summary, + config: { ...h.ctx.config, budgetTokens: 5000, maxEditsPerRun: 5, skillsDir: ".agents/skills" }, + }, + ); + assert.equal(built.proposal.edits.length, 0); + assert.ok(built.violations.some((violation) => /backed by 1 session\(s\); 2 are required/.test(violation))); + } + } +}); + +test("an occupied incompatible observer preserves its native alias through normalization and recording", () => { + const native = (id, observer, gaps = []) => { + const evidence = record(id, gaps); + evidence.transcript = { ...evidence.transcript, harness: "pi", nativeId: id, corroborationIdentity: observer }; + evidence.key = evidenceKey(evidence.transcript, evidence.memoryHash); + return evidence; + }; + const selected = [native("P", "P"), native("G", "L"), native("H", "P"), native("X", "X")]; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [native("P", "P", [GAP]), native("G", "P", [GAP]), native("H", "H", [GAP])], { + now: new Date("2026-08-02T00:00:00Z"), + }); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const historical = structuredClone(ledger.entries[entryId].sessions.P); + const valid = structuredClone(ledger.entries[entryId].sessions.H); + normalizeGapLedgerSessions( + ledger, + selected.map((evidence) => evidence.transcript), + ); + assert.deepEqual(ledger.entries[entryId].sessions.P, historical); + assert.deepEqual(ledger.entries[entryId].sessions.H, valid); + recordGapObservations(ledger, [native("H", "P", [GAP])], { + now: new Date("2026-08-03T00:00:00Z"), + }); + assert.deepEqual( + ledger.entries[entryId].sessions.P, + historical, + "standalone recording also rejects unresolved destinations", + ); + assert.deepEqual(ledger.entries[entryId].sessions.H.sightingIds, ["H"]); + recordGapObservations(ledger, [native("H", "P", [GAP])], { + transcripts: selected.map((evidence) => evidence.transcript), + now: new Date("2026-08-03T00:00:00Z"), + }); + assert.deepEqual(ledger.entries[entryId].sessions.P, historical); + assert.equal(ledger.entries[entryId].sessions.H.firstObservedAt, valid.firstObservedAt); + assert.deepEqual(ledger.entries[entryId].sessions.H.sightingIds, ["H"]); + assert.deepEqual(ledger.entries[entryId].sessions.H.sightingQuotes, ["quote from H"]); +}); + +test("unresolved occupied destinations do not absorb valid selected native aliases", () => { + const native = (id, observer) => ({ + id, + identity: id, + harness: "pi", + corroborationIdentity: observer, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + interaction: "interactive", + }); + for (const scenario of [ + { selected: [native("H", "P")] }, + { selected: [native("H", "P"), native("G", "P"), native("G", "L")] }, + { selected: [native("H", "P"), native("G", "P")], unattributedSightings: true }, + ]) { + const entryId = gapEntryId(MEMORY_PATH, GAP); + const unresolved = { + observedAt: "2026-08-01T00:00:00Z", + sourceSessionId: "G", + sightingIds: ["G"], + source: "pi · G · 2026-08-01", + quote: "unresolved G quote", + ...(scenario.unattributedSightings ? { unattributedSightings: true } : {}), + }; + const valid = { + observedAt: "2026-08-02T00:00:00Z", + sourceSessionId: "H", + sightingIds: ["H"], + source: "pi · H · 2026-08-01", + quote: "valid H quote", + }; + const ledger = { + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: { P: structuredClone(unresolved), H: structuredClone(valid) }, + }, + }, + }; + normalizeGapLedgerSessions(ledger, scenario.selected); + assert.deepEqual(ledger.entries[entryId].sessions.P, unresolved); + assert.deepEqual(ledger.entries[entryId].sessions.H, valid); + const fresh = { ...record("H", [GAP]), transcript: scenario.selected[0] }; + recordGapObservations(ledger, [fresh], { transcripts: scenario.selected }); + assert.deepEqual(ledger.entries[entryId].sessions.P, unresolved); + assert.deepEqual(ledger.entries[entryId].sessions.H.sightingIds, ["H"]); + assert.deepEqual( + new Set(ledger.entries[entryId].sessions.H.sightingQuotes), + new Set(["valid H quote", "quote from H"]), + ); + } +}); + +test("selected native history corroborates separately from an incompatible occupied observer on disk", async () => { + for (const reanalyzed of [false, true]) { + for (const matchingGap of [false, true]) { + const h = harness({ gapLedgerMaxAge: "all" }); + const native = (id, observer, gaps, memoryHash) => { + const evidence = record(id, gaps, { memoryHash }); + evidence.transcript = { + ...evidence.transcript, + harness: "pi", + nativeId: id, + corroborationIdentity: observer, + }; + evidence.key = evidenceKey(evidence.transcript, memoryHash); + return evidence; + }; + const ledger = { version: 1, entries: {} }; + recordGapObservations( + ledger, + [native("P", "P", [GAP], "h1"), native("G", "P", [GAP], "h1"), native("H", "H", [GAP], "h1")], + { now: new Date("2026-08-02T00:00:00Z") }, + ); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const historical = structuredClone(ledger.entries[entryId].sessions.P); + const valid = structuredClone(ledger.entries[entryId].sessions.H); + h.state.writeGapLedger(ledger); + const current = [ + native("P", "P", [], reanalyzed ? "h2" : "h1"), + native("G", "L", [], reanalyzed ? "h2" : "h1"), + native("H", "P", matchingGap ? [GAP] : [], reanalyzed || matchingGap ? "h2" : "h1"), + native("X", "X", [GAP], "h2"), + ]; + for (const evidence of current) h.state.writeEvidence(evidence.transcript.id, evidence); + const summary = await foldForRun( + h.ctx, + memoryFile(), + "h2", + [], + current.map((evidence) => evidence.transcript), + { + route: { weight: null, rootPath: MEMORY_PATH, ownerOf: () => null }, + }, + ); + assert.equal(summary.analyzedSessions, reanalyzed ? 4 : matchingGap ? 2 : 1); + assert.equal(summary.totals.gapSightings, 2); + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), ["quote from H", "quote from X"]); + assert.equal(summary.sourceObservers["pi · H · 2026-08-01"], "P"); + assert.equal(summary.sourceSessions["pi · H · 2026-08-01"], "H"); + assert.ok(!summary.rootOwnedGaps.flat().some((item) => /quote from [PG]$/.test(item.quote))); + const sessions = h.state.readGapLedger().entries[entryId].sessions; + assert.deepEqual(sessions.P, historical); + if (!matchingGap) assert.deepEqual(sessions.H, valid); + else { + assert.equal(sessions.H.firstObservedAt, valid.firstObservedAt); + assert.deepEqual(sessions.H.sightingIds, ["H"]); + assert.deepEqual(sessions.H.sightingQuotes, ["quote from H"]); + } + const repo = makeRepo({ [MEMORY_PATH]: "# T\n\n- Run pnpm test before pushing.\n- Keep the README current.\n" }); + const staged = stageAndMeasure({ + repo, + edit: (root) => writeIn(root, MEMORY_PATH, (text) => `${text}- ${GAP}\n`), + }); + const build = (quotes) => + buildProposal( + { + edits: [ + { + changes: staged.measured.changes.map((change) => change.id), + kind: "add", + title: "Read the database documentation", + rationale: "Avoid re-deriving query behavior", + evidence: quotes.map((quote) => ({ polarity: "negative", ...quote })), + }, + ], + }, + { + repo, + memoryFile: staged.memoryFile, + measured: staged.measured, + summary, + config: { ...h.ctx.config, budgetTokens: 5000, maxEditsPerRun: 5, skillsDir: ".agents/skills" }, + }, + ); + assert.equal(build(summary.gaps[0].quotes).proposal.edits.length, 1); + const hQuote = summary.gaps[0].quotes.find((quote) => quote.text === "quote from H"); + const duplicate = build([hQuote, hQuote]); + assert.equal(duplicate.proposal.edits.length, 0); + assert.ok(duplicate.violations.some((violation) => /backed by 1 session\(s\); 2 are required/.test(violation))); + } + } +}); + +test("fresh native evidence retains a deterministic sibling when its own observer slot is incompatible", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const native = (id, observer, gaps, memoryHash) => { + const evidence = record(id, gaps, { memoryHash }); + evidence.transcript = { ...evidence.transcript, harness: "pi", nativeId: id, corroborationIdentity: observer }; + evidence.key = evidenceKey(evidence.transcript, memoryHash); + return evidence; + }; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [native("P", "P", [GAP], "h1"), native("G", "P", [GAP], "h1")]); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const historical = structuredClone(ledger.entries[entryId].sessions.P); + h.state.writeGapLedger(ledger); + const current = [native("P", "P", [GAP], "h2"), native("G", "L", [], "h2"), native("X", "X", [GAP], "h2")]; + for (const evidence of current) h.state.writeEvidence(evidence.transcript.id, evidence); + for (let run = 0; run < 2; run += 1) { + const summary = await foldForRun( + h.ctx, + memoryFile(), + "h2", + [], + current.map((evidence) => evidence.transcript), + ); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.totals.gapSightings, 2); + assert.deepEqual(summary.gaps[0].quotes.map((quote) => quote.text).sort(), ["quote from P", "quote from X"]); + const sessions = h.state.readGapLedger().entries[entryId].sessions; + assert.deepEqual(sessions.P, historical); + assert.equal(Object.keys(sessions).length, 3); + assert.deepEqual(sessions[JSON.stringify(["P", "P"])].sightingIds, ["P"]); + assert.deepEqual(sessions[JSON.stringify(["P", "P"])].sightingQuotes, ["quote from P"]); + } +}); + +test("selected historical sightings remain admitted when their representative is not selected", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const historical = { + observedAt: "2026-08-01T00:00:00Z", + source: "pi · G · 2026-08-01", + sourceSessionId: "G", + sightingIds: ["P", "G"], + quote: "historical G quote", + sightingQuotes: ["historical P quote", "historical G quote"], + }; + h.state.writeGapLedger({ + version: 1, + entries: { + [entryId]: { id: entryId, memoryPath: MEMORY_PATH, proposedInstruction: GAP, sessions: { P: historical } }, + }, + }); + const parent = record("P", [], { memoryHash: "h2" }); + parent.transcript = { ...parent.transcript, harness: "pi", corroborationIdentity: "P" }; + parent.key = evidenceKey(parent.transcript, "h2"); + const independent = record("X", [GAP], { memoryHash: "h2" }); + for (const evidence of [parent, independent]) h.state.writeEvidence(evidence.transcript.id, evidence); + const route = { weight: null, rootPath: MEMORY_PATH, ownerOf: () => null }; + const summary = await foldForRun(h.ctx, memoryFile(), "h2", [], [parent.transcript, independent.transcript], { + route, + }); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.sourceObservers[historical.source], "P"); + assert.equal(summary.sourceSessions[historical.source], "G"); + assert.deepEqual( + new Set( + summary.rootOwnedGaps + .flat() + .filter((item) => item.sessionId === "P") + .map((item) => item.quote), + ), + new Set(historical.sightingQuotes), + ); + assert.deepEqual(h.state.readGapLedger().entries[entryId].sessions.P, historical); +}); + +test("fresh matching evidence cannot bypass rejected attributed alias migration", async () => { + for (const destination of [null, "other-observer"]) { + const h = harness({ gapLedgerMaxAge: "all" }); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const old = { + observedAt: "2026-08-01T00:00:00Z", + source: "old native source", + sourceSessionId: "G", + sightingIds: ["G", "H"], + quote: "old mixed quote", + }; + h.state.writeGapLedger({ + version: 1, + entries: { + [entryId]: { id: entryId, memoryPath: MEMORY_PATH, proposedInstruction: GAP, sessions: { G: old } }, + }, + }); + const fresh = record("G", [GAP], { memoryHash: "h2" }); + fresh.transcript = { ...fresh.transcript, harness: "pi", corroborationIdentity: "R" }; + fresh.key = evidenceKey(fresh.transcript, "h2"); + h.state.writeEvidence(fresh.transcript.id, fresh); + const selected = [fresh.transcript]; + if (destination) + selected.push({ + ...record("H", [], { memoryHash: "h2" }).transcript, + harness: "pi", + corroborationIdentity: destination, + }); + const summary = await foldForRun(h.ctx, memoryFile(), "h2", [], selected); + assert.equal(summary.analyzedSessions, 1); + assert.deepEqual(summary.gaps, []); + assert.equal(summary.totals.gapSightings, 1); + const sessions = h.state.readGapLedger().entries[entryId].sessions; + assert.deepEqual(sessions.G, old); + assert.equal(sessions.R.sourceSessionId, "G"); + assert.deepEqual(sessions.R.sightingIds, ["G"]); + assert.deepEqual(sessions.R.sightingQuotes, ["quote from G"]); + } +}); + +test("direct recording still migrates provenance-free legacy aliases", () => { + const entryId = gapEntryId(MEMORY_PATH, GAP); + const ledger = { + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: { legacy: { observedAt: "2026-08-01T00:00:00Z", quote: "legacy quote" } }, + }, + }, + }; + const fresh = record("legacy", [GAP]); + fresh.transcript.identity = "current-native"; + recordGapObservations(ledger, [fresh], { legacyIds: new Set(["legacy"]) }); + assert.equal(ledger.entries[entryId].sessions.legacy, undefined); + const observation = ledgerGapObservations(ledger, MEMORY_PATH)[0]; + assert.equal(observation.sessionId, "current-native"); + assert.equal(observation.sourceSessionId, "current-native"); + assert.deepEqual(new Set(observation.sightingQuotes), new Set(["legacy quote", "quote from legacy"])); +}); + test("the same session is never double-counted across runs", async () => { const h = harness(); await run(h, [record("claude-s1", [GAP])]); @@ -126,6 +698,448 @@ 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, + 2, + "unresolved history and fresh native evidence remain separate sightings", + ); + 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 keeps unresolved legacy parent history separate with one observer vote", 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), + }); + const before = structuredClone(Object.values(ledger.entries)[0].sessions); + 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); + const sessions = Object.values(h.state.readGapLedger().entries)[0].sessions; + assert.deepEqual(sessions, before, "the unresolved occupied parent and valid child stay intact on disk"); + assert.deepEqual(Object.keys(sessions).sort(), [parentIdentity, child.identity].sort()); +}); + +test("native aliases migrate while root-only observations keep unknown provenance", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const children = [1, 2].map((index) => ({ + id: `pi-child-${index}`, + identity: `native-child-${index}`, + nativeId: `child-${index}`, + harness: "pi", + startedAt: Date.parse("2026-08-02T00:00:00Z"), + corroborationIdentity: `root-${index}`, + corroborationNativeId: `parent-${index}`, + corroborationStartedAt: Date.parse("2026-08-01T00:00:00Z"), + interaction: "non-interactive", + cwd: h.root, + })); + const entryId = gapEntryId(MEMORY_PATH, GAP); + const legacy = { + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: Object.fromEntries( + children.map((child) => [ + child.identity, + { + observedAt: "2026-08-03T00:00:00Z", + source: "old native source", + quote: `quote ${child.nativeId}`, + domain: "project", + }, + ]), + ), + }, + }, + }; + h.state.writeGapLedger(legacy); + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const attribution = new Map( + children.map((child) => [ + child.identity, + workedPaths(child, [{ kind: "tool", input: { path: "apps/api/handler.ts" } }], [h.root]), + ]), + ); + const route = routingFor([api], attribution, MEMORY_PATH, api.path); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], children, { route }); + assert.equal(summary.analyzedSessions, 0); + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual( + summary.gaps[0].quotes.map((quote) => quote.source), + ["pi · child-1 · 2026-08-02", "pi · child-2 · 2026-08-02"], + ); + const migrated = h.state.readGapLedger(); + for (const child of children) { + const observation = migrated.entries[entryId].sessions[child.corroborationIdentity]; + assert.equal(observation.sourceSessionId, child.identity); + assert.deepEqual(observation.sightingIds, [child.identity]); + } + + const rootOnly = { + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: Object.fromEntries( + children.map((child) => [ + child.corroborationIdentity, + { + observedAt: "2026-08-03T00:00:00Z", + source: `original source ${child.corroborationIdentity}`, + quote: "unknown native quote", + domain: "project", + }, + ]), + ), + }, + }, + }; + h.state.writeGapLedger(rootOnly); + const unknown = await foldForRun(h.ctx, memoryFile(), "h1", [], children, { route }); + assert.deepEqual(unknown.gaps, [], "without native attribution the ledger cannot claim an API owner"); + assert.equal(unknown.routedGaps[0].owner, MEMORY_PATH); + assert.deepEqual(unknown.sourceSessions, {}); + assert.deepEqual(unknown.sources, ["original source root-1", "original source root-2"]); + assert.ok( + Object.values(h.state.readGapLedger().entries[entryId].sessions).every( + (observation) => observation.sourceSessionId === undefined && observation.sightingIds === undefined, + ), + ); +}); + +test("native sightings union across recording, migration and consolidation with one root vote", () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const gapA = "Always verify the API schema before adding a handler."; + const gapB = "Check deployment permissions when releasing services."; + const native = (id, root, gap, relative) => { + const transcript = { + id: `pi-${id}`, + identity: id, + nativeId: id, + harness: "pi", + corroborationIdentity: root, + startedAt: Date.parse("2026-08-02T00:00:00Z"), + interaction: "non-interactive", + cwd: h.root, + }; + return { + ...record(id, [gap]), + transcript, + relative, + }; + }; + const records = [ + native("child-api", "observer-a", gapA, "apps/api/a.ts"), + native("child-web", "observer-a", gapB, "apps/web/a.ts"), + native("independent-api", "observer-b", gapA, "apps/api/b.ts"), + ]; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + const apiEntry = Object.values(ledger.entries).find((entry) => entry.proposedInstruction === gapA); + const webEntry = Object.values(ledger.entries).find((entry) => entry.proposedInstruction === gapB); + assert.equal(mergeGapEntries(ledger, [[apiEntry.id, webEntry.id]]), 1); + normalizeGapLedgerSessions( + ledger, + records.map((record) => record.transcript), + ); + const observations = ledgerGapObservations(ledger, MEMORY_PATH); + const shared = observations.find((observation) => observation.sessionId === "observer-a"); + assert.equal(shared.sourceSessionId, "child-api"); + assert.deepEqual(shared.sightingIds.sort(), ["child-api", "child-web"]); + assert.equal(shared.source, "pi · child-api · 2026-08-02"); + assert.deepEqual(shared.sightingQuotes.sort(), ["quote from child-api", "quote from child-web"]); + const attribution = new Map( + records.map((record) => [ + record.transcript.identity, + workedPaths(record.transcript, [{ kind: "tool", input: { path: record.relative } }], [h.root]), + ]), + ); + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const route = routingFor([api], attribution, MEMORY_PATH, null); + const summary = foldEvidence([], { minGapEvidence: 2, gapObservations: observations, route }); + assert.equal(summary.gaps.length, 1); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.sourceSessions[shared.source], "child-api"); + assert.equal(summary.sourceObservers[shared.source], "observer-a"); + assert.equal( + foldEvidence([], { + minGapEvidence: 2, + gapObservations: observations, + route: { ...route, weight: api.path }, + }).gaps.length, + 0, + ); + + recordGapObservations(ledger, [records[1]]); + const preserved = ledgerGapObservations(ledger, MEMORY_PATH); + const prior = preserved.find((observation) => observation.sessionId === "observer-a"); + assert.deepEqual(prior, shared); + const fresh = preserved.find((observation) => observation.sessionId === "child-web"); + assert.equal(fresh.sourceSessionId, "child-web"); + assert.deepEqual(fresh.sightingIds, ["child-web"]); + assert.deepEqual(fresh.sightingQuotes, ["quote from child-web"]); + const refolded = foldEvidence(records, { minGapEvidence: 2, gapObservations: preserved, route }); + assert.equal(refolded.gaps[0].sessions, 2); +}); + +test("ordinary non-OMP selected legacy observations keep their native routing identity", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const selected = [record("ordinary-a", [GAP]), record("ordinary-b", [GAP])].map((record) => ({ + ...record.transcript, + cwd: h.root, + })); + const entryId = gapEntryId(MEMORY_PATH, GAP); + h.state.writeGapLedger({ + version: 1, + entries: { + [entryId]: { + id: entryId, + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: Object.fromEntries( + selected.map((transcript) => [ + transcript.identity, + { + observedAt: "2026-08-03T00:00:00Z", + source: `claude · ${transcript.id} · 2026-08-01`, + quote: `quote ${transcript.id}`, + }, + ]), + ), + }, + }, + }); + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const attribution = new Map( + selected.map((transcript) => [ + transcript.identity, + workedPaths(transcript, [{ kind: "tool", input: { path: "apps/api/handler.ts" } }], [h.root]), + ]), + ); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], selected, { + route: routingFor([api], attribution, MEMORY_PATH, api.path), + }); + assert.equal(summary.gaps[0].sessions, 2); + for (const observation of ledgerGapObservations(h.state.readGapLedger(), MEMORY_PATH)) { + assert.equal(observation.sourceSessionId, observation.sessionId); + assert.deepEqual(observation.sightingIds, [observation.sessionId]); + assert.equal(summary.sourceSessions[observation.source], observation.sessionId); + } +}); + +test("absorbed unknown sightings stay root-owned through merge and overwrite", () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const child = { + id: "pi-child", + identity: "native-api-child", + nativeId: "child", + harness: "pi", + corroborationIdentity: "observer-root", + cwd: h.root, + startedAt: Date.parse("2026-08-02T00:00:00Z"), + interaction: "non-interactive", + }; + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const attribution = new Map([ + [child.identity, workedPaths(child, [{ kind: "tool", input: { path: "apps/api/handler.ts" } }], [h.root])], + ]); + for (const unknownFirst of [false, true]) { + const evidence = { ...record(child.id, [GAP]), transcript: child }; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [evidence]); + const known = Object.values(ledger.entries)[0]; + const unknownId = "f".repeat(16); + ledger.entries[unknownId] = { + id: unknownId, + memoryPath: MEMORY_PATH, + proposedInstruction: "Legacy phrasing.", + sessions: { + "observer-root": { + source: "legacy observer source", + quote: "unknown native quote", + observedAt: "2026-08-01T00:00:00Z", + }, + }, + }; + mergeGapEntries(ledger, [unknownFirst ? [unknownId, known.id] : [known.id, unknownId]]); + normalizeGapLedgerSessions(ledger, [child]); + recordGapObservations(ledger, [ + { + ...evidence, + gaps: [{ ...evidence.gaps[0], matchesGap: Object.keys(ledger.entries)[0] }], + }, + ]); + const observations = ledgerGapObservations(ledger, MEMORY_PATH); + assert.equal(observations.length, 1); + assert.deepEqual(observations[0].sightingIds, [child.identity]); + assert.equal(observations[0].sourceSessionId, child.identity); + assert.equal(observations[0].unattributedSightings, true); + const root = foldEvidence([], { + gapObservations: observations, + minGapEvidence: 1, + route: routingFor([api], attribution, MEMORY_PATH, null), + }); + assert.equal(root.gaps.length, 1); + assert.equal(root.sourceSessions[observations[0].source], undefined); + assert.equal( + foldEvidence([], { + gapObservations: observations, + minGapEvidence: 1, + route: routingFor([api], attribution, MEMORY_PATH, api.path), + }).gaps.length, + 0, + ); + } +}); + +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, transcripts: [parent.transcript, child.transcript] }); + const [entry] = Object.values(ledger.entries); + assert.deepEqual(Object.keys(entry.sessions), [rootIdentity]); + return { ledger, domain: entry.sessions[rootIdentity].domain }; + }; + + assert.equal(domainAfter([parent, child]).domain, "project"); + assert.equal(domainAfter([child, parent]).domain, "project"); + + const { ledger } = domainAfter([parent]); + assert.equal( + domainAfter([child], ledger, new Date(startedAt + 2 * DAY)).domain, + "orchestration", + "a later run still replaces the earlier vote", + ); +}); test("a legacy session-id observation migrates without counting the identity as a second session", async () => { const h = harness(); @@ -145,6 +1159,71 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); +test("an ambiguous legacy session id never migrates onto a selected session", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, + }); + const a = shared("session-a"); + const b = shared("session-b"); + const c = record("claude-c", []).transcript; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [record("claude-shared", [GAP]), record("claude-c", [GAP])]); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); + + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a, c]); + + assert.equal(summary.gaps.length, 0, "a sighting that may be B's must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-c", "claude-shared"]); +}); + +test("a selected session recording a gap never inherits an ambiguous legacy id's sighting", async () => { + const h = harness(); + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, + }); + const a = shared("session-a"); + const b = shared("session-b"); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const ledger = { version: 1, entries: {} }; + const bFirstObservedAt = new Date(Date.now() - 80 * DAY); + recordGapObservations(ledger, [record("claude-shared", [GAP])], { now: bFirstObservedAt }); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [GAP])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); + + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a]); + + assert.equal(summary.gaps.length, 0, "B's sighting must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-shared", "session-a"]); + assert.equal(entry.sessions["claude-shared"].firstObservedAt, bFirstObservedAt.toISOString()); + assert.ok( + Date.parse(entry.sessions["session-a"].firstObservedAt) > bFirstObservedAt.getTime(), + "A's fresh sighting keeps its own first-seen time", + ); +}); + test("a genuine one-off never graduates, however many runs see it", async () => { const h = harness(); for (let i = 0; i < 5; i += 1) { diff --git a/test/interaction.test.js b/test/interaction.test.js index 2ede9b1..17199bb 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -25,6 +25,7 @@ import { foldForRun, printProposal } from "../src/commands/propose.js"; import { cmdScan } from "../src/commands/scan.js"; import { renderApplySurface } from "../src/apply/lavish.js"; import { evidenceKey, State } from "../src/state.js"; +import { recordGapObservations } from "../src/gap-ledger.js"; import { transcriptIdentity } from "../src/transcript.js"; import { sampleTranscripts, capTranscripts } from "../src/sample.js"; import { setLoggerSink } from "../src/logger.js"; @@ -478,6 +479,44 @@ test("fold excludes legacy evidence without an interaction category", async () = assert.equal(state.readEvidence(transcript).transcript.interaction, undefined); }); +test("fold keeps legacy evidence excluded when current discovery stamps an interaction", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-fold-current-")); + const state = new State(dir).ensure(); + const transcript = { + harness: "codex", + id: "codex-legacy-current", + nativeId: "legacy-current", + path: "/sessions/legacy-current.jsonl", + mtimeMs: 100, + bytes: 200, + interaction: INTERACTIVE, + }; + const memoryHash = "sha256:memory"; + state.writeEvidence(transcript, { + status: "ok", + transcript: { harness: "codex", id: transcript.id, path: transcript.path }, + memoryHash, + memoryPath: "AGENTS.md", + key: evidenceKey(transcript, memoryHash), + positive: [{ instruction: "AG-001", quote: "followed the repository rule exactly" }], + negative: [], + gaps: [], + }); + + const summary = await foldForRun( + { + repo: { root: dir }, + config: { state, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, + }, + { path: "AGENTS.md", text: "", units: [] }, + memoryHash, + [], + [transcript], + ); + + assert.equal(summary.analyzedSessions, 0, "only analysis may backfill a stored interaction stamp"); +}); + test("fold selection distinguishes colliding native IDs by source", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-identity-fold-")); const state = new State(dir).ensure(); @@ -749,6 +788,324 @@ test("evidence records carry the category and fold reports relevance per categor process.env.HOME = prevHome; } }); +test("OMP subagents share their parent identity and refresh cached relations", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(parentPath, header("parent-native")); + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const cache = config.state.readScanCache(); + for (const candidate of pi.enumerate({ config })) { + const descriptor = pi.classify(candidate); + delete descriptor.parentSessionId; + delete descriptor.parentSessionPath; + delete descriptor.parentSessionStartedAt; + cache.entries[`pi:${candidate.key}`] = { + mtimeMs: candidate.mtimeMs, + bytes: candidate.bytes, + descriptor, + }; + } + config.state.writeScanCache(cache); + + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(first.perHarness.pi.cached, 0, "the pre-relation cache must be reclassified"); + const parent = first.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity, "the two files remain separately analyzable"); + assert.equal(parent.corroborationIdentity, parent.identity); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(parent.interaction, INTERACTIVE); + assert.equal(child.interaction, NON_INTERACTIVE); + + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 2, "the refreshed relation is safe to reuse"); + assert.equal( + second.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + config.discovery.includeOmp = false; + const disabled = await discoverTranscripts({ repo: repository, config }); + assert.deepEqual(disabled.transcripts, [], "opting out must not replay cached OMP descriptors"); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("Pi child cache is invalidated when its parent session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-cache-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + + const first = await discoverTranscripts({ repo: repository, config }); + const childBeforeParent = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(childBeforeParent); + assert.equal(childBeforeParent.parentSessionId, undefined); + assert.equal(childBeforeParent.interaction, NON_INTERACTIVE); + assert.equal(childBeforeParent.corroborationIdentity, childBeforeParent.identity); + const cachedMissingParent = await discoverTranscripts({ repo: repository, config }); + assert.equal(cachedMissingParent.perHarness.pi.cached, 1); + assert.equal(cachedMissingParent.transcripts[0].interaction, NON_INTERACTIVE); + + writeJsonl(parentPath, header("parent-native")); + const second = await discoverTranscripts({ repo: repository, config }); + const parent = second.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = second.transcripts.find((transcript) => transcript.nativeId === "child-native"); + + assert.ok(parent && child); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(second.perHarness.pi.cached, 0, "the child descriptor must be reclassified after its parent appears"); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 2, "the refreshed parent relation is safe to reuse"); + assert.equal( + third.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("nested OMP automation stays non-interactive with unreadable ancestors and refreshes legacy caches", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-unreadable-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const childPath = path.join(sessionRoot, "root", "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, "other-root", "Missing", "Missing.Child.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")); + writeJsonl(grandchildPath, header("grandchild-native")); + writeJsonl(path.join(sessionRoot, "root.jsonl"), [{ type: "title", title: "unreadable header" }]); + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const cache = config.state.readScanCache(); + for (const candidate of pi.enumerate({ config })) { + const descriptor = pi.classify(candidate); + if (descriptor) descriptor.interactionSignals = {}; + cache.entries[`pi:${candidate.key}`] = { + cacheVersion: 4, + cacheDependency: pi.cacheDependency(candidate), + 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); + assert.equal(first.transcripts.length, 2); + for (const transcript of first.transcripts) { + assert.equal(transcript.interaction, NON_INTERACTIVE); + assert.equal(classifyInteraction({ ...transcript, interaction: INTERACTIVE }), NON_INTERACTIVE); + assert.equal(transcript.parentSessionId, undefined); + assert.equal(transcript.corroborationIdentity, transcript.identity); + assert.equal(transcript.corroborationNativeId, transcript.nativeId); + assert.equal(transcript.corroborationStartedAt, transcript.startedAt); + } + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 3); + assert.ok(second.transcripts.every((transcript) => transcript.interaction === NON_INTERACTIVE)); + + const memoryHash = "sha256:memory"; + for (const transcript of second.transcripts) { + config.state.writeEvidence(transcript, { + status: "ok", + transcript: { ...transcript, interaction: INTERACTIVE }, + memoryHash, + memoryPath: "AGENTS.md", + key: evidenceKey(transcript, memoryHash), + positive: [], + negative: [], + gaps: [], + }); + } + const summary = await analyzeTranscripts({ + transcripts: second.transcripts, + memoryFile: { path: "AGENTS.md" }, + config: { ...config, agents: { resolve: () => assert.fail("fresh evidence must not invoke an agent") } }, + repo: repository, + memoryHash, + }); + assert.deepEqual([summary.cached, summary.analyzed], [2, 0]); + const evidence = config.state.listEvidence(); + assert.ok(evidence.every((record) => record.transcript.interaction === NON_INTERACTIVE)); + assert.ok(evidence.every((record) => record.transcript.parentSessionId === null)); + assert.deepEqual(foldEvidence(evidence).analyzedByInteraction, { [INTERACTIVE]: 0, [NON_INTERACTIVE]: 2 }); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("nested OMP subagents share the root identity once the root session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, timestamp) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp, cwd: repo }, + ]; + writeJsonl(childPath, header("child-native", "2026-08-27T00:01:00.000Z")); + writeJsonl(grandchildPath, header("grandchild-native", "2026-08-27T00:02:00.000Z")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + config.gapLedgerMaxAge = "all"; + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const byNativeId = (result, nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(byNativeId(first, "child-native").parentSessionId, undefined); + assert.equal(byNativeId(first, "grandchild-native").parentSessionId, "child-native"); + + writeJsonl(rootPath, header("root-native", "2026-08-27T00:00:00.000Z")); + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 0, "both descendants must be reclassified when the root appears"); + const root = byNativeId(second, "root-native"); + const child = byNativeId(second, "child-native"); + const grandchild = byNativeId(second, "grandchild-native"); + assert.ok(root && child && grandchild); + assert.equal(new Set([root.identity, child.identity, grandchild.identity]).size, 3, "each file stays analyzable"); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.corroborationStartedAt, root.startedAt); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.corroborationIdentity, root.identity); + assert.equal(root.interaction, INTERACTIVE); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 3, "the refreshed relations are safe to reuse"); + assert.equal(byNativeId(third, "grandchild-native").corroborationIdentity, root.identity); + + const perFile = (transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + memoryHash: "sha256:memory", + transcript: { ...transcript, parentSessionId: null, corroborationIdentity: null, corroborationNativeId: null }, + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived it", + quote: "quote", + recurrenceRisk: "high", + }, + ], + }); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [perFile(child), perFile(grandchild)]); + config.state.writeGapLedger(ledger); + + const summary = await foldForRun( + { repo: { root: repo }, config }, + { path: "AGENTS.md", text: "", units: [] }, + "sha256:memory", + [], + [child, grandchild], + ); + assert.equal(summary.gaps.length, 0, "per-file sightings of one root session are one observer"); + const [entry] = Object.values(config.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions), [root.identity]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("OMP subagents running in another repo cwd still share the root identity", async () => { + const repo = initRepo(); + const subdir = path.join(repo, "packages", "api"); + fs.mkdirSync(subdir, { recursive: true }); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, cwd) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd }, + ]; + writeJsonl(rootPath, header("root-native", repo)); + writeJsonl(childPath, header("child-native", subdir)); + writeJsonl(grandchildPath, header("grandchild-native", subdir)); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const result = await discoverTranscripts({ repo: repository, config }); + const byNativeId = (nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + const root = byNativeId("root-native"); + const child = byNativeId("child-native"); + const grandchild = byNativeId("grandchild-native"); + + assert.ok(root && child && grandchild, "a subagent in a repo subdirectory still maps to this repository"); + assert.equal(child.cwd, subdir); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.interaction, INTERACTIVE); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); test("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ diff --git a/test/nested.test.js b/test/nested.test.js index eedb2f8..ed74962 100644 --- a/test/nested.test.js +++ b/test/nested.test.js @@ -6,11 +6,12 @@ import path from "node:path"; import { loadConfig } from "../src/config.js"; import { nestedCorpora, primaryMemoryFile } from "../src/commands/analyze.js"; +import { foldForRun } from "../src/commands/propose.js"; import { foldEvidence, renderEvidenceForPrompt } from "../src/fold.js"; import { setLoggerSink } from "../src/logger.js"; import { buildProposal } from "../src/proposal.js"; import { applyDecisions } from "../src/apply/writer.js"; -import { State } from "../src/state.js"; +import { evidenceKey, State } from "../src/state.js"; import { transcriptIdentity } from "../src/transcript.js"; import { applyNestedMemoryConfig, @@ -441,6 +442,375 @@ test("the fold keeps only the gap clusters this file owns, and names where the o assert.ok(!("routedGaps" in unrouted) && !("sourceSessions" in unrouted), "no routing, no new summary fields"); }); +test("OMP child native ownership agrees across direct and ledger-only folds", async () => { + const repo = makeRepo({ + "AGENTS.md": "# Root\n\n- Use pnpm.\n", + "apps/api/AGENTS.md": "# API\n\n- Keep handlers small.\n", + }); + const staged = new Map( + [null, API.path].map((weight) => [ + weight, + stageAndMeasure({ + repo, + memoryPath: weight ?? "AGENTS.md", + edit: (root) => writeIn(root, weight ?? "AGENTS.md", (text) => `${text}- ${API_GAP}\n`), + }), + ]), + ); + const roots = checkoutRoots(repo); + const attribution = new Map(); + const transcripts = []; + for (const index of [1, 2]) { + const root = { + id: `pi-root-${index}`, + nativeId: `root-${index}`, + identity: `pi-file-root-${index}`, + harness: "pi", + cwd: repo.root, + startedAt: Date.parse("2026-08-01T00:00:00Z"), + interaction: "interactive", + }; + const child = { + id: `pi-child-${index}`, + nativeId: `child-${index}`, + identity: `pi-file-child-${index}`, + harness: "pi", + cwd: repo.root, + parentSessionId: root.nativeId, + corroborationIdentity: root.identity, + corroborationNativeId: root.nativeId, + corroborationStartedAt: root.startedAt, + startedAt: Date.parse("2026-08-02T00:00:00Z"), + interaction: "non-interactive", + }; + transcripts.push(root, child); + attribution.set(root.identity, workedPaths(root, [], roots)); + attribution.set( + child.identity, + workedPaths(child, [{ kind: "tool", input: { path: `apps/api/handler-${index}.ts` } }], roots), + ); + } + const expectedSources = ["pi · child-1 · 2026-08-02", "pi · child-2 · 2026-08-02"]; + const ctx = { + repo, + config: { + state: new State(repo.root).ensure(), + minGapEvidence: 2, + gapLedgerMaxAge: "all", + memoryFiles: ["AGENTS.md"], + }, + }; + for (const weight of [null, API.path]) { + const pass = weight === null ? ctx : nestedContext(ctx, API); + const selected = weight === null ? transcripts : transcripts.filter((transcript) => transcript.parentSessionId); + const route = routingFor([API], attribution, "AGENTS.md", weight); + const measurement = staged.get(weight); + const file = measurement.memoryFile; + const records = selected.map((transcript) => ({ + ...evidenceRecord(transcript.identity, API_GAP), + transcript, + memoryPath: file.path, + memoryHash: file.hash, + key: evidenceKey(transcript, file.hash), + gaps: transcript.parentSessionId ? evidenceRecord(transcript.identity, API_GAP).gaps : [], + })); + for (const record of records) pass.config.state.writeEvidence(record.transcript, record); + const direct = foldEvidence(records, { minGapEvidence: 2, route }); + const admitted = await foldForRun(pass, file, file.hash, [], selected, { route }); + const persisted = await foldForRun(pass, file, "changed-memory-hash", [], selected, { route }); + assert.equal(direct.analyzedSessions, selected.length); + assert.equal(admitted.analyzedSessions, selected.length); + assert.equal(persisted.analyzedSessions, 0); + for (const summary of [direct, admitted, persisted]) { + if (weight === null) { + assert.deepEqual(summary.gaps, []); + assert.deepEqual( + summary.routedGaps.map((gap) => [gap.owner, gap.sessions]), + [[API.path, 2]], + ); + } else { + assert.equal(summary.gaps.length, 1); + assert.equal(summary.gaps[0].sessions, 2); + assert.deepEqual( + summary.gaps[0].quotes.map((quote) => quote.source), + expectedSources, + ); + } + for (const [index, source] of expectedSources.entries()) { + assert.equal(summary.sourceSessions[source], `pi-file-child-${index + 1}`); + assert.equal(summary.sourceObservers[source], `pi-file-root-${index + 1}`); + } + const result = buildProposal( + { + edits: [ + { + changes: measurement.measured.changes.map((change) => change.id), + kind: "add", + title: "API contract", + evidence: expectedSources.map((source, index) => ({ + polarity: "negative", + text: `pi-file-child-${index + 1} hit it`, + source, + })), + }, + ], + }, + { + memoryFile: file, + config: { budgetTokens: 5000, maxEditsPerRun: 5, minGapEvidence: 2, skillsDir: ".agents/skills" }, + repo, + summary, + measured: measurement.measured, + routing: route, + }, + ); + if (weight === null) { + assert.equal(result.proposal.edits.length, 0); + assert.ok(result.violations.some((violation) => violation.includes(`it belongs in ${API.path}`))); + } else { + assert.deepEqual(result.violations, []); + assert.equal(result.proposal.edits[0].transcripts, 2); + } + } + } +}); + +test("an unknown native observer beside an API child keeps both the fold and proposal root-owned", () => { + const repo = makeRepo({ + "AGENTS.md": "# Root\n\n- Use pnpm.\n", + "apps/api/AGENTS.md": "# API\n\n- Keep handlers small.\n", + }); + const child = { identity: "native-api-child", cwd: repo.root }; + const attribution = new Map([ + [ + child.identity, + workedPaths(child, [{ kind: "tool", input: { path: "apps/api/handler.ts" } }], checkoutRoots(repo)), + ], + ]); + const observations = [ + { + proposedInstruction: API_GAP, + sessionId: "observer-api", + sourceSessionId: child.identity, + sightingIds: [child.identity], + source: "pi · api-child · 2026-08-02", + quote: "API child quote", + }, + { + proposedInstruction: API_GAP, + sessionId: "legacy-observer", + source: "pi · legacy-root · 2026-08-01", + quote: "unknown native quote", + }, + ]; + const rootRoute = routingFor([API], attribution, "AGENTS.md", null); + const rootSummary = foldEvidence([], { + minGapEvidence: 2, + gapObservations: observations, + route: rootRoute, + }); + assert.equal(rootSummary.gaps[0].sessions, 2); + for (const weight of [null, API.path]) { + const route = { ...rootRoute, weight, rootOwnedGaps: rootSummary.rootOwnedGaps }; + const summary = foldEvidence([], { minGapEvidence: 2, gapObservations: observations, route }); + if (weight === null) assert.equal(summary.gaps.length, 1); + else { + assert.deepEqual(summary.gaps, []); + assert.equal(summary.routedGaps[0].owner, "AGENTS.md"); + } + const staged = stageAndMeasure({ + repo, + memoryPath: weight ?? "AGENTS.md", + edit: (root) => writeIn(root, weight ?? "AGENTS.md", (text) => `${text}- ${API_GAP}\n`), + }); + const result = buildProposal( + { + edits: [ + { + changes: staged.measured.changes.map((change) => change.id), + kind: "add", + title: "API contract", + evidence: observations.map((observation) => ({ + polarity: "negative", + text: observation.quote, + source: observation.source, + })), + }, + ], + }, + { + memoryFile: staged.memoryFile, + measured: staged.measured, + config: { budgetTokens: 5000, maxEditsPerRun: 5, minGapEvidence: 2, skillsDir: ".agents/skills" }, + repo, + summary, + routing: route, + }, + ); + if (weight === null) { + assert.deepEqual(result.violations, []); + assert.equal(result.proposal.edits[0].transcripts, 2); + } else { + assert.equal(result.proposal.edits.length, 0); + assert.ok( + result.violations.some((violation) => violation.includes("cross-cutting evidence belongs in AGENTS.md")), + ); + } + const withoutRootOverride = buildProposal( + { + edits: [ + { + changes: staged.measured.changes.map((change) => change.id), + kind: "add", + title: "API contract", + evidence: observations.map((observation) => ({ + polarity: "negative", + text: observation.quote, + source: observation.source, + })), + }, + ], + }, + { + memoryFile: staged.memoryFile, + measured: staged.measured, + config: { budgetTokens: 5000, maxEditsPerRun: 5, minGapEvidence: 2, skillsDir: ".agents/skills" }, + repo, + summary: { ...summary, rootOwnedGaps: [] }, + routing: { ...route, rootOwnedGaps: [] }, + }, + ); + assert.equal(withoutRootOverride.proposal.edits.length, weight === null ? 1 : 0); + } +}); + +test("same-root API and WEB quote variants keep the API sibling root-pinned", async () => { + const repo = makeRepo({ + "AGENTS.md": "# Root\n\n- Use pnpm.\n", + "apps/api/AGENTS.md": "# API\n\n- Keep handlers small.\n", + }); + const staged = stageAndMeasure({ + repo, + memoryPath: API.path, + edit: (root) => writeIn(root, API.path, (text) => `${text}- ${API_GAP}\n`), + }); + const records = ["shared-web", "shared-api", "independent-api"].map((id) => ({ + ...evidenceRecord(id, API_GAP), + transcript: { + id: `pi-${id}`, + identity: id, + nativeId: id, + harness: "pi", + cwd: repo.root, + startedAt: Date.parse("2026-08-02T00:00:00Z"), + parentSessionId: id.startsWith("shared") ? "shared-root" : "independent-root", + corroborationIdentity: id.startsWith("shared") ? "shared-observer" : "independent-observer", + interaction: "non-interactive", + }, + })); + const selected = records.filter((record) => !record.transcript.id.includes("web")); + const attribution = new Map( + records.map((record) => [ + record.transcript.identity, + workedPaths( + record.transcript, + [ + { + kind: "tool", + input: { path: record.transcript.id.includes("web") ? "apps/web/view.ts" : "apps/api/handler.ts" }, + }, + ], + checkoutRoots(repo), + ), + ]), + ); + const rootRoute = routingFor([API, WEB], attribution, "AGENTS.md", null); + const ctx = { + repo, + config: { + state: new State(repo.root).ensure(), + minGapEvidence: 2, + gapLedgerMaxAge: "all", + memoryFiles: ["AGENTS.md"], + }, + }; + const rootFile = { ...staged.memoryFile, path: "AGENTS.md", hash: "root-hash" }; + const rootRecords = records.map((record) => ({ + ...record, + memoryPath: rootFile.path, + memoryHash: rootFile.hash, + key: evidenceKey(record.transcript, rootFile.hash), + })); + for (const record of rootRecords) ctx.config.state.writeEvidence(record.transcript, record); + const transcripts = records.map((record) => record.transcript); + const directRoot = foldEvidence(rootRecords, { minGapEvidence: 2, route: rootRoute }); + const admittedRoot = await foldForRun(ctx, rootFile, rootFile.hash, [], transcripts, { route: rootRoute }); + const persistedRoot = await foldForRun(ctx, rootFile, "changed-root-hash", [], transcripts, { + route: rootRoute, + }); + const nested = nestedContext(ctx, API); + const nestedRecords = selected.map((record) => ({ + ...record, + memoryPath: API.path, + memoryHash: staged.memoryFile.hash, + key: evidenceKey(record.transcript, staged.memoryFile.hash), + })); + for (const record of nestedRecords) nested.config.state.writeEvidence(record.transcript, record); + const nestedTranscripts = selected.map((record) => record.transcript); + for (const root of [directRoot, admittedRoot, persistedRoot]) { + assert.equal(root.gaps.length, 1); + assert.equal(root.gaps[0].sessions, 2); + assert.ok( + root.rootOwnedGaps[0].some((item) => item.sessionId === "shared-observer" && item.quote === "shared-web hit it"), + ); + assert.ok( + root.rootOwnedGaps[0].some((item) => item.sessionId === "shared-observer" && item.quote === "shared-api hit it"), + ); + const route = { ...rootRoute, weight: API.path, rootOwnedGaps: root.rootOwnedGaps }; + const directApi = foldEvidence(nestedRecords, { minGapEvidence: 2, route }); + const admittedApi = await foldForRun(nested, staged.memoryFile, staged.memoryFile.hash, [], nestedTranscripts, { + route, + }); + const persistedApi = await foldForRun(nested, staged.memoryFile, "changed-api-hash", [], nestedTranscripts, { + route, + }); + for (const summary of [directApi, admittedApi, persistedApi]) { + assert.deepEqual(summary.gaps, []); + assert.equal(summary.routedGaps[0].owner, "AGENTS.md"); + const result = buildProposal( + { + edits: [ + { + changes: staged.measured.changes.map((change) => change.id), + kind: "add", + title: "API contract", + evidence: selected.map((record) => ({ + polarity: "negative", + text: record.gaps[0].quote, + source: `pi · ${record.transcript.nativeId} · 2026-08-02`, + })), + }, + ], + }, + { + memoryFile: staged.memoryFile, + measured: staged.measured, + config: { budgetTokens: 5000, maxEditsPerRun: 5, minGapEvidence: 2, skillsDir: ".agents/skills" }, + repo, + summary, + routing: route, + }, + ); + assert.equal(result.proposal.edits.length, 0); + assert.ok( + result.violations.some((violation) => violation.includes("cross-cutting evidence belongs in AGENTS.md")), + ); + } + } + assert.equal(directRoot.gaps[0].quotes[0].text, "shared-web hit it"); + assert.equal(selected[0].gaps[0].quote, "shared-api hit it"); +}); + test("mixed-directory sessions never enter a nested analysis corpus or own its gap", async () => { const transcripts = ["api-1", "both", "root-file-and-api"].map((identity) => ({ identity })); const { corpora } = await nestedCorpora({}, [API], transcripts, ATTRIBUTION); diff --git a/test/proposal.test.js b/test/proposal.test.js index 4b84420..44dd6d8 100644 --- a/test/proposal.test.js +++ b/test/proposal.test.js @@ -15,6 +15,8 @@ import { SHRINK_MAX_EDITS, } from "../src/proposal.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; +import { ledgerGapObservations, mergeGapEntries, recordGapObservations } from "../src/gap-ledger.js"; +import { rootOwnsGap, routingFor } from "../src/nested.js"; import { estimateTokens } from "../src/tokens.js"; import { loadProjectSkills, skillDescriptionTokens } from "../src/skills.js"; import { parseMemoryUnits, unitHash } from "../src/memory.js"; @@ -595,6 +597,275 @@ test("a quote counts as a session only when the fold issued its source label", ( ); }); +test("native OMP quote labels still measure one proposal observer per root", () => { + const observed = (nativeId, observer) => ({ + status: "ok", + transcript: { + id: `pi-${nativeId}`, + identity: `pi-file-${nativeId}`, + nativeId, + harness: "pi", + startedAt: Date.parse("2026-08-02T00:00:00Z"), + corroborationIdentity: observer, + corroborationNativeId: observer, + corroborationStartedAt: Date.parse("2026-08-01T00:00:00Z"), + interaction: "non-interactive", + }, + negative: [{ instruction: "AG-001", quote: `harm ${nativeId}`, class: "harm" }], + gaps: [{ proposedInstruction: "Read the API contract first.", quote: `gap ${nativeId}` }], + }); + for (const independent of [false, true]) { + const summary = foldEvidence([ + observed("child-a", "root-a"), + observed("child-b", independent ? "root-b" : "root-a"), + ]); + assert.equal(summary.analyzedSessions, 2); + assert.equal(summary.sources.length, 2); + assert.equal(summary.instructions[0].harmSessions, independent ? 2 : 1); + assert.equal(summary.gaps.length, independent ? 1 : 0); + const result = gate({ + edit: memoryEdit(REWRITE_SHAPES["append a sentence"]), + annotation: { + edits: [ + claim(["H1"], { + kind: "rewrite", + evidence: summary.instructions[0].quotes.map((quote) => ({ + polarity: "negative", + text: quote.text, + source: quote.source, + })), + }), + ], + }, + context: { summary }, + }); + if (independent) { + assert.deepEqual(result.violations, []); + assert.equal(result.proposal.edits[0].transcripts, 2); + } else { + assert.equal(result.proposal.edits.length, 0); + assert.ok(result.violations.some((violation) => /backed by 1 session\(s\); 2 are required/.test(violation))); + } + } +}); + +test("shared observer gap sightings do not widen native ownership of unrelated quote sources", () => { + const sharedGap = "Check deployment permissions before releasing services."; + const apiAddition = "Validate the API schema before adding a handler."; + const observed = (id, observer, negative) => ({ + status: "ok", + memoryPath: "AGENTS.md", + transcript: { + id, + identity: id, + nativeId: id, + harness: "pi", + startedAt: Date.parse("2026-08-02T00:00:00Z"), + corroborationIdentity: observer, + interaction: "non-interactive", + }, + negative: [{ instruction: "AG-001", quote: negative, class: "harm" }], + gaps: [{ proposedInstruction: sharedGap, quote: `deployment mistake ${id}`, recurrenceRisk: "high" }], + }); + const a = observed("native-a", "observer-r", "API schema mismatch in handler A"); + const b = observed("native-b", "observer-r", "WEB rendering mistake in component B"); + const c = observed("native-c", "observer-c", "API schema mismatch in handler C"); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [b, a, c], { now: new Date("2026-08-03T00:00:00Z") }); + const observations = ledgerGapObservations(ledger, "AGENTS.md"); + const shared = observations.find((observation) => observation.sessionId === "observer-r"); + assert.equal(shared.sourceSessionId, a.transcript.identity); + assert.deepEqual(shared.sightingIds, [b.transcript.identity, a.transcript.identity]); + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const attribution = new Map([ + [a.transcript.identity, ["apps/api/a.ts"]], + [b.transcript.identity, ["apps/web/b.ts"]], + [c.transcript.identity, ["apps/api/c.ts"]], + ]); + const route = routingFor([api], attribution, "AGENTS.md", null); + const summary = foldEvidence([a, c], { gapObservations: observations, route }); + const apiEvidence = summary.instructions[0].quotes.map((quote) => ({ + polarity: "negative", + text: quote.text, + source: quote.source, + })); + assert.equal(summary.sourceSessions[shared.source], a.transcript.identity); + assert.equal(summary.sourceObservers[shared.source], "observer-r"); + assert.equal( + foldEvidence([], { gapObservations: observations, route }).sourceSessions[shared.source], + a.transcript.identity, + ); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.instructions[0].harmSessions, 2); + assert.equal( + rootOwnsGap( + apiEvidence.map((quote) => ({ + sessionId: summary.sourceObservers[quote.source], + quote: quote.text, + })), + summary.rootOwnedGaps, + ), + false, + ); + assert.equal( + foldEvidence([a, c], { + gapObservations: observations, + route: { ...route, weight: api.path }, + }).gaps.length, + 0, + ); + const propose = (memoryPath, instruction, evidence) => { + const repo = makeRepo({ "AGENTS.md": MEMORY_TEXT, [api.path]: MEMORY_TEXT }); + const staged = stageAndMeasure({ + repo, + memoryPath, + edit: (root) => writeIn(root, memoryPath, (text) => `${text}- ${instruction}\n`), + }); + return buildProposal( + { + edits: [ + claim( + staged.measured.changes.map((change) => change.id), + { kind: "add", evidence }, + ), + ], + }, + { + repo, + memoryFile: staged.memoryFile, + measured: staged.measured, + config: config(), + summary, + routing: { ...route, weight: memoryPath === "AGENTS.md" ? null : memoryPath }, + }, + ); + }; + const wrongRoot = propose("AGENTS.md", apiAddition, apiEvidence); + assert.equal(wrongRoot.proposal.edits.length, 0); + assert.ok(wrongRoot.violations.some((violation) => /belongs in apps\/api\/AGENTS.md/.test(violation))); + const apiResult = propose(api.path, apiAddition, apiEvidence); + assert.deepEqual(apiResult.violations, []); + assert.equal(apiResult.proposal.edits.length, 1); + assert.equal(apiResult.proposal.edits[0].transcripts, 2); + const gapEvidence = observations.map((observation) => ({ + polarity: "negative", + text: observation.quote, + source: observation.source, + })); + const rootGap = propose("AGENTS.md", sharedGap, gapEvidence); + assert.deepEqual(rootGap.violations, []); + assert.equal(rootGap.proposal.edits.length, 1); + const launderedGap = propose(api.path, sharedGap, gapEvidence); + assert.equal(launderedGap.proposal.edits.length, 0); + assert.ok(launderedGap.violations.some((violation) => /cross-cutting evidence belongs in AGENTS.md/.test(violation))); +}); + +test("unknown historical gaps preserve current native attribution for unrelated API quotes", () => { + const deployment = "Check deployment permissions before releasing services."; + const apiAddition = "Validate the API schema before adding a handler."; + const observed = (id, observer) => ({ + status: "ok", + memoryPath: "AGENTS.md", + transcript: { + id, + identity: id, + nativeId: id, + harness: "pi", + startedAt: Date.parse("2026-08-02T00:00:00Z"), + corroborationIdentity: observer, + interaction: "non-interactive", + }, + negative: [{ instruction: "AG-001", quote: `API schema mismatch in handler ${id}`, class: "harm" }], + gaps: [{ proposedInstruction: deployment, quote: `deployment mistake ${id}`, recurrenceRisk: "high" }], + }); + const a = observed("native-a", "observer-a"); + const b = observed("native-b", "observer-b"); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [a, b]); + const knownId = Object.keys(ledger.entries)[0]; + const unknownId = "f".repeat(16); + ledger.entries[unknownId] = { + id: unknownId, + memoryPath: "AGENTS.md", + proposedInstruction: "Legacy deployment phrasing.", + sessions: { + "observer-a": { + source: "legacy deployment source", + quote: "unknown deployment mistake", + observedAt: "2026-08-01T00:00:00Z", + }, + }, + }; + mergeGapEntries(ledger, [[knownId, unknownId]]); + recordGapObservations(ledger, [a]); + const observations = ledgerGapObservations(ledger, "AGENTS.md"); + assert.equal(observations.find((observation) => observation.sessionId === "observer-a").unattributedSightings, true); + const api = { path: "apps/api/AGENTS.md", dir: "apps/api" }; + const attribution = new Map([ + [a.transcript.identity, ["apps/api/a.ts"]], + [b.transcript.identity, ["apps/api/b.ts"]], + ]); + const route = routingFor([api], attribution, "AGENTS.md", null); + const summary = foldEvidence([a, b], { gapObservations: observations, route }); + const apiEvidence = summary.instructions[0].quotes.map((quote) => ({ + polarity: "negative", + text: quote.text, + source: quote.source, + })); + assert.equal(summary.gaps[0].sessions, 2); + assert.equal(summary.instructions[0].harmSessions, 2); + const propose = (memoryPath, instruction, evidence) => { + const repo = makeRepo({ "AGENTS.md": MEMORY_TEXT, [api.path]: MEMORY_TEXT }); + const staged = stageAndMeasure({ + repo, + memoryPath, + edit: (root) => writeIn(root, memoryPath, (text) => `${text}- ${instruction}\n`), + }); + return buildProposal( + { + edits: [ + claim( + staged.measured.changes.map((change) => change.id), + { kind: "add", evidence }, + ), + ], + }, + { + repo, + memoryFile: staged.memoryFile, + measured: staged.measured, + config: config(), + summary, + routing: { ...route, weight: memoryPath === "AGENTS.md" ? null : memoryPath }, + }, + ); + }; + const apiResult = propose(api.path, apiAddition, apiEvidence); + assert.deepEqual(apiResult.violations, []); + assert.equal(apiResult.proposal.edits[0].transcripts, 2); + const wrongRoot = propose("AGENTS.md", apiAddition, apiEvidence); + assert.equal(wrongRoot.proposal.edits.length, 0); + assert.ok(wrongRoot.violations.some((violation) => /belongs in apps\/api\/AGENTS.md/.test(violation))); + const gapEvidence = observations.map((observation) => ({ + polarity: "negative", + text: observation.quote, + source: observation.source, + })); + const rootGap = propose("AGENTS.md", deployment, gapEvidence); + assert.deepEqual(rootGap.violations, []); + assert.equal(rootGap.proposal.edits[0].transcripts, 2); + const launderedGap = propose(api.path, deployment, gapEvidence); + assert.equal(launderedGap.proposal.edits.length, 0); + assert.ok(launderedGap.violations.some((violation) => /cross-cutting evidence belongs in AGENTS.md/.test(violation))); + assert.equal( + foldEvidence([a, b], { + gapObservations: observations, + route: { ...route, weight: api.path }, + }).gaps.length, + 0, + ); +}); + test("on the r1 dry-run corpus, only the edit whose second source was never issued is refused", () => { const foldRecord = (id, day, quote) => ({ status: "ok", diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index 9658a6d..8b72ed1 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -4,8 +4,21 @@ import fs from "node:fs"; import path from "node:path"; import { applyHostFlag, loadConfig } from "../src/config.js"; -import { discoverProject, initRepo, sshCalls, tmpdir, withRemoteEnv, writeClaudeSession } from "./helpers/remote.js"; -import { disambiguateSourceLabels, gapSource } from "../src/gap-ledger.js"; +import { + discoverProject, + initRepo, + projectRun, + sshCalls, + tmpdir, + withRemoteEnv, + writeClaudeSession, +} from "./helpers/remote.js"; +import { + disambiguateSourceLabels, + gapSource, + ledgerGapObservations, + recordGapObservations, +} from "../src/gap-ledger.js"; import { classifySshFailure, closeSshMasters } from "../src/discovery/remote/ssh.js"; import { discoverTranscripts } from "../src/discovery/index.js"; import { resolveHostList } from "../src/discovery/hosts.js"; @@ -14,6 +27,8 @@ import { State } from "../src/state.js"; import { SELF_SESSION_SENTINEL } from "../src/sentinel.js"; import { UserError, setLoggerSink } from "../src/logger.js"; import { clearProgressSink, setProgressSink } from "../src/progress.js"; +import { foldEvidence } from "../src/fold.js"; +import { INTERACTIVE, NON_INTERACTIVE } from "../src/interaction.js"; const REMOTE = "github.com/acme/demo"; @@ -40,6 +55,712 @@ function scenario({ variant = {}, cwdOverride = null, sessionText = null } = {}) }; } +function writeOmpSession(file, id, cwd, timestamp = "2026-08-27T00:00:00.000Z") { + 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 })}\n`, + ); +} + +async function withRemotePiEnv(scenario, fn) { + const keys = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "BB_DATA_DIR", "BB_PI_BRIDGE_SESSION_DIR"]; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) delete process.env[key]; + try { + return await withRemoteEnv({ localHome: scenario.localHome, hosts: scenario.hosts }, fn); + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } +} + +function observerRecords(transcripts) { + return transcripts.map((transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + transcript, + negative: [{ instruction: "db-rule", class: "harm", quote: "following the rule caused damage" }], + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived the schema", + quote: "read the schema", + recurrenceRisk: "high", + domain: "project", + }, + ], + })); +} + +function assertSingletonObserver(transcripts, identity) { + const records = observerRecords(transcripts); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 0); + assert.equal(folded.totals.droppedGapSingletons, 1); + assert.equal(folded.instructions[0].harmSessions, 1); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + const sessions = Object.values(ledger.entries)[0].sessions; + assert.deepEqual(Object.keys(sessions), [identity]); + assert.deepEqual(sessions[identity].sightingIds.sort(), transcripts.map((transcript) => transcript.identity).sort()); + assert.equal(sessions[identity].sourceSessionId, transcripts.at(-1).identity); + assert.equal(sessions[identity].source, gapSource(transcripts.at(-1))); + assert.ok(sessions[identity].source.includes(transcripts.at(-1).nativeId)); + const persisted = foldEvidence(records, { + minGapEvidence: 2, + gapObservations: ledgerGapObservations(ledger, "AGENTS.md"), + }); + assert.equal(persisted.gaps.length, 0); + assert.equal(persisted.instructions[0].harmSessions, 1); +} + +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"); + writeOmpSession(parentPath, "parent-native", s.remoteClone); + writeOmpSession(childPath, "child-native", s.remoteClone, "2026-08-27T00:01:00.000Z"); + + const piEnv = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "BB_DATA_DIR", "BB_PI_BRIDGE_SESSION_DIR"]; + const previous = Object.fromEntries(piEnv.map((key) => [key, process.env[key]])); + for (const key of piEnv) delete process.env[key]; + let result; + try { + const disabled = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all" }, + }), + ); + assert.deepEqual(disabled.transcripts, [], "the remote OMP store is opt-in too"); + result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); + } finally { + for (const key of piEnv) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + + const parent = result.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = result.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(child.corroborationNativeId, "parent-native"); + assert.equal(child.corroborationStartedAt, parent.startedAt); +}); + +test("a copied OMP root and retained remote descendants count as one observer without merging local split roots", async () => { + const s = scenario(); + const rootName = "2026-08-27T00-00-00.000Z_root"; + const localRoot = path.join(s.localHome, ".omp", "agent", "sessions", "-repo-demo"); + const remoteRoot = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); + writeOmpSession(path.join(localRoot, `${rootName}.jsonl`), "root-native", s.repoRoot); + writeOmpSession(path.join(remoteRoot, `${rootName}.jsonl`), "root-native", s.remoteClone); + writeOmpSession(path.join(remoteRoot, rootName, "Child.jsonl"), "child-native", s.remoteClone); + writeOmpSession( + path.join(remoteRoot, rootName, "Child", "Child.Grandchild.jsonl"), + "grandchild-native", + s.remoteClone, + ); + const discover = () => + withRemotePiEnv(s, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); + const result = await discover(); + assert.equal(result.transcripts.length, 3); + assert.equal(result.perHost[0].duplicates, 1); + const root = result.transcripts.find((transcript) => transcript.nativeId === "root-native"); + assert.equal(root.host, null); + assert.equal(new Set(result.transcripts.map((transcript) => transcript.identity)).size, 3); + assert.equal(new Set(result.transcripts.map((transcript) => transcript.nativeId)).size, 3); + for (const descendant of result.transcripts.filter((transcript) => transcript !== root)) { + assert.equal(descendant.host, "mac-home"); + assert.ok(descendant.path.startsWith(remoteRoot)); + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + const records = result.transcripts.map((transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + transcript, + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived the schema", + quote: "read the schema", + recurrenceRisk: "high", + domain: "project", + }, + ], + })); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 0); + assert.equal(folded.totals.droppedGapSingletons, 1); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + assert.deepEqual(Object.keys(Object.values(ledger.entries)[0].sessions), [root.identity]); + + const splitPath = path.join(s.localHome, ".pi", "agent", "sessions", "-repo-demo", "split-root.jsonl"); + writeOmpSession(splitPath, "root-native", s.repoRoot); + writeOmpSession(path.join(localRoot, rootName, "Child.jsonl"), "child-native", s.repoRoot); + writeOmpSession(path.join(remoteRoot, "independent.jsonl"), "independent-native", s.remoteClone); + const split = await discover(); + const roots = split.transcripts.filter((transcript) => transcript.nativeId === "root-native"); + assert.equal(roots.length, 2); + assert.ok(roots.every((transcript) => transcript.host === null)); + assert.equal(new Set(roots.map((transcript) => transcript.corroborationIdentity)).size, 2); + const localChild = split.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.equal(localChild.host, null); + assert.equal(localChild.corroborationIdentity, root.identity); + const grandchild = split.transcripts.find((transcript) => transcript.nativeId === "grandchild-native"); + const splitRoot = roots.find((transcript) => transcript.path === splitPath); + assert.equal(grandchild.corroborationIdentity, localChild.corroborationIdentity); + assert.notEqual(grandchild.corroborationIdentity, splitRoot.identity); + assertSingletonObserver([localChild, grandchild], root.identity); + const independentRootEvidence = foldEvidence(observerRecords([localChild, grandchild, splitRoot]), { + minGapEvidence: 2, + }); + assert.equal(independentRootEvidence.gaps.length, 1); + assert.equal(independentRootEvidence.instructions[0].harmSessions, 2); + const independent = split.transcripts.find((transcript) => transcript.nativeId === "independent-native"); + assert.ok(independent); + assert.equal(independent.corroborationIdentity, independent.identity); + assert.ok(roots.every((transcript) => transcript.corroborationIdentity !== independent.identity)); +}); + +for (const remoteOnly of [false, true]) { + for (const filtered of [false, true]) { + test(`copied orphan siblings reconcile every readable-root correspondence (${remoteOnly ? "remote-only" : "local"}, ${filtered ? "filtered" : "all"})`, async () => { + const s = scenario(); + const now = Date.parse("2026-08-28T12:00:00.000Z"); + const old = "2026-08-27T00:00:00.000Z"; + const recent = "2026-08-28T11:00:00.000Z"; + const firstHome = remoteOnly ? s.remoteHome : s.localHome; + const firstCwd = remoteOnly ? s.remoteClone : s.repoRoot; + const secondHome = remoteOnly ? tmpdir("remote-other") : s.remoteHome; + const secondCwd = remoteOnly + ? initRepo(path.join(secondHome, "code", "demo"), `https://${REMOTE}.git`) + : s.remoteClone; + if (remoteOnly) s.hosts["mac-other"] = { home: secondHome }; + const rootName = "2026-08-27T00-00-00.000Z_root"; + const firstDirectory = path.join(firstHome, ".omp", "agent", "sessions", "-repo-demo"); + const secondDirectory = path.join(secondHome, ".omp", "agent", "sessions", "-repo-demo"); + const write = (file, id, cwd, timestamp) => { + writeOmpSession(file, id, cwd, timestamp); + fs.utimesSync(file, Date.parse(timestamp) / 1000, Date.parse(timestamp) / 1000); + }; + write(path.join(firstDirectory, `${rootName}.jsonl`), "root-native", firstCwd, old); + fs.writeFileSync(path.join(firstDirectory, `${rootName}.jsonl`), "not a session header\n"); + write(path.join(secondDirectory, `${rootName}.jsonl`), "root-native", secondCwd, old); + for (const [name, id] of [ + ["A", "a-native"], + ["B", "b-native"], + ]) { + write(path.join(firstDirectory, rootName, `${name}.jsonl`), id, firstCwd, recent); + write(path.join(secondDirectory, rootName, `${name}.jsonl`), id, secondCwd, recent); + } + write(path.join(secondDirectory, rootName, "C.jsonl"), "c-native", secondCwd, recent); + const result = await withRemotePiEnv(s, async () => { + const options = projectRun(s.repoRoot, { + discovery: { + hosts: remoteOnly ? ["mac-home", "mac-other"] : ["mac-home"], + harnesses: ["pi"], + since: filtered ? "1d" : "all", + includeOmp: true, + }, + }); + const discovered = await discoverTranscripts({ ...options, now }); + await closeSshMasters(discovered.remoteMasters); + return discovered; + }); + assert.equal(result.transcripts.length, filtered ? 3 : 4); + assert.equal(result.perHost.at(-1).duplicates, 2); + const a = result.transcripts.find((transcript) => transcript.nativeId === "a-native"); + const b = result.transcripts.find((transcript) => transcript.nativeId === "b-native"); + const c = result.transcripts.find((transcript) => transcript.nativeId === "c-native"); + assert.ok(a && b && c); + assert.notEqual(a.identity, b.identity); + for (const transcript of [a, b]) { + assert.equal(transcript.host, remoteOnly ? "mac-home" : null); + assert.equal(transcript.parentSessionId, undefined); + assert.equal(transcript.corroborationNativeId, transcript.nativeId); + assert.equal(transcript.interactionSignals.source, "subagent"); + assert.equal(transcript.interaction, NON_INTERACTIVE); + } + assert.equal(c.host, remoteOnly ? "mac-other" : "mac-home"); + assert.equal(c.parentSessionId, "root-native"); + assert.equal(c.corroborationNativeId, "root-native"); + assert.equal(c.corroborationStartedAt, Date.parse(old)); + assert.equal(new Set(result.transcripts.map((transcript) => transcript.corroborationIdentity)).size, 1); + assertSingletonObserver(result.transcripts, a.corroborationIdentity); + }); + } +} + +for (const filtered of [false, true]) { + test(`established orphan correspondence wins native-root fallback on later hosts (${filtered ? "filtered" : "all"})`, async () => { + const s = scenario(); + const otherHome = tmpdir("remote-other"); + const otherClone = initRepo(path.join(otherHome, "code", "demo"), `https://${REMOTE}.git`); + s.hosts["mac-other"] = { home: otherHome }; + const now = Date.parse("2026-08-28T12:00:00.000Z"); + const old = "2026-08-27T00:00:00.000Z"; + const recent = "2026-08-28T11:00:00.000Z"; + const rootName = "2026-08-27T00-00-00.000Z_root"; + const localDirectory = path.join(s.localHome, ".omp", "agent", "sessions", "-repo-demo"); + const remoteDirectory = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); + const otherDirectory = path.join(otherHome, ".omp", "agent", "sessions", "-repo-demo"); + const independentPath = path.join(s.localHome, ".pi", "agent", "sessions", "-repo-demo", "split-root.jsonl"); + const write = (file, id, cwd, timestamp) => { + writeOmpSession(file, id, cwd, timestamp); + fs.utimesSync(file, Date.parse(timestamp) / 1000, Date.parse(timestamp) / 1000); + }; + write(independentPath, "root-native", s.repoRoot, recent); + write(path.join(localDirectory, `${rootName}.jsonl`), "root-native", s.repoRoot, old); + fs.writeFileSync(path.join(localDirectory, `${rootName}.jsonl`), "not a session header\n"); + write(path.join(localDirectory, rootName, "A.jsonl"), "a-native", s.repoRoot, recent); + write(path.join(remoteDirectory, `${rootName}.jsonl`), "root-native", s.remoteClone, old); + write(path.join(remoteDirectory, rootName, "A.jsonl"), "a-native", s.remoteClone, recent); + write(path.join(remoteDirectory, rootName, "B.jsonl"), "b-native", s.remoteClone, recent); + write(path.join(otherDirectory, `${rootName}.jsonl`), "root-native", otherClone, old); + write(path.join(otherDirectory, rootName, "C.jsonl"), "c-native", otherClone, recent); + const result = await withRemotePiEnv(s, async () => { + const options = projectRun(s.repoRoot, { + discovery: { + hosts: ["mac-home", "mac-other"], + harnesses: ["pi"], + since: filtered ? "1d" : "all", + includeOmp: true, + }, + }); + const discovered = await discoverTranscripts({ ...options, now }); + await closeSshMasters(discovered.remoteMasters); + return discovered; + }); + assert.equal(result.transcripts.length, 4); + assert.deepEqual( + result.perHost.map((host) => host.duplicates), + filtered ? [1, 0] : [2, 1], + ); + const independent = result.transcripts.find((transcript) => transcript.path === independentPath); + const a = result.transcripts.find((transcript) => transcript.nativeId === "a-native"); + const b = result.transcripts.find((transcript) => transcript.nativeId === "b-native"); + const c = result.transcripts.find((transcript) => transcript.nativeId === "c-native"); + assert.ok(independent && a && b && c); + assert.equal(independent.host, null); + assert.equal(independent.interaction, INTERACTIVE); + assert.equal(independent.parentSessionId, undefined); + assert.equal(independent.corroborationIdentity, independent.identity); + assert.equal(a.host, null); + assert.equal(a.parentSessionId, undefined); + assert.equal(a.interactionSignals.source, "subagent"); + assert.equal(a.corroborationNativeId, "a-native"); + assert.equal(a.corroborationIdentity, a.identity); + assert.equal(b.host, "mac-home"); + assert.equal(c.host, "mac-other"); + for (const transcript of [b, c]) { + assert.equal(transcript.parentSessionId, "root-native"); + assert.equal(transcript.corroborationNativeId, "root-native"); + assert.equal(transcript.corroborationStartedAt, Date.parse(old)); + assert.equal(transcript.corroborationIdentity, a.identity); + assert.equal(transcript.interaction, NON_INTERACTIVE); + assert.notEqual(transcript.identity, a.identity); + } + assert.notEqual(independent.corroborationIdentity, a.corroborationIdentity); + assertSingletonObserver([b, c], a.identity); + assertSingletonObserver([a, b, c], a.identity); + const records = observerRecords([independent, b, c]); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 1); + assert.equal(folded.instructions[0].harmSessions, 2); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + assert.deepEqual( + Object.keys(Object.values(ledger.entries)[0].sessions).sort(), + [independent.identity, a.identity].sort(), + ); + const persisted = foldEvidence(records, { + minGapEvidence: 2, + gapObservations: ledgerGapObservations(ledger, "AGENTS.md"), + }); + assert.equal(persisted.gaps.length, 1); + assert.equal(persisted.instructions[0].harmSessions, 2); + }); +} + +for (const remoteOnly of [false, true]) { + for (const filtered of [false, true]) { + for (const familyStore of [".omp", ".pi"]) { + test(`copied descendants retain their split-root family (${remoteOnly ? "remote-only" : "local"}, ${filtered ? "filtered" : "all"}, ${familyStore})`, async () => { + const s = scenario(); + const now = Date.parse("2026-08-28T12:00:00.000Z"); + const old = "2026-08-27T00:00:00.000Z"; + const recent = "2026-08-28T11:00:00.000Z"; + const firstHome = remoteOnly ? s.remoteHome : s.localHome; + const firstCwd = remoteOnly ? s.remoteClone : s.repoRoot; + const secondHome = remoteOnly ? tmpdir("remote-other") : s.remoteHome; + const secondCwd = remoteOnly + ? initRepo(path.join(secondHome, "code", "demo"), `https://${REMOTE}.git`) + : s.remoteClone; + if (remoteOnly) s.hosts["mac-other"] = { home: secondHome }; + const rootName = "2026-08-27T00-00-00.000Z_root"; + const familyDirectory = path.join(firstHome, familyStore, "agent", "sessions", "-repo-demo"); + const independentStore = familyStore === ".omp" ? ".pi" : ".omp"; + const independentPath = path.join( + firstHome, + independentStore, + "agent", + "sessions", + "-repo-demo", + "split-root.jsonl", + ); + const familyPath = path.join(familyDirectory, `${rootName}.jsonl`); + const childPath = path.join(familyDirectory, rootName, "Child.jsonl"); + const copiedDirectory = path.join(secondHome, ".omp", "agent", "sessions", "-repo-demo"); + const grandchildPath = path.join(copiedDirectory, rootName, "Child", "Child.Grandchild.jsonl"); + const write = (file, id, cwd, timestamp) => { + writeOmpSession(file, id, cwd, timestamp); + fs.utimesSync(file, Date.parse(timestamp) / 1000, Date.parse(timestamp) / 1000); + }; + write(independentPath, "root-native", firstCwd, recent); + write(familyPath, "root-native", firstCwd, old); + write(childPath, "child-native", firstCwd, recent); + write(path.join(copiedDirectory, `${rootName}.jsonl`), "root-native", secondCwd, old); + write(path.join(copiedDirectory, rootName, "Child.jsonl"), "child-native", secondCwd, recent); + write(grandchildPath, "grandchild-native", secondCwd, recent); + const result = await withRemotePiEnv(s, async () => { + const options = projectRun(s.repoRoot, { + discovery: { + hosts: remoteOnly ? ["mac-home", "mac-other"] : ["mac-home"], + harnesses: ["pi"], + since: filtered ? "1d" : "all", + includeOmp: true, + }, + }); + const discovered = await discoverTranscripts({ ...options, now }); + await closeSshMasters(discovered.remoteMasters); + return discovered; + }); + assert.equal(result.transcripts.length, filtered ? 3 : 4); + assert.equal(result.perHost.at(-1).duplicates, filtered ? 1 : 2); + const independent = result.transcripts.find((transcript) => transcript.path === independentPath); + const family = result.transcripts.find((transcript) => transcript.path === familyPath); + const child = result.transcripts.find((transcript) => transcript.nativeId === "child-native"); + const grandchild = result.transcripts.find((transcript) => transcript.nativeId === "grandchild-native"); + assert.ok(independent && child && grandchild); + assert.equal(child.path, childPath); + assert.equal(child.host, remoteOnly ? "mac-home" : null); + assert.equal(grandchild.path, grandchildPath); + assert.equal(grandchild.host, remoteOnly ? "mac-other" : "mac-home"); + assert.notEqual(child.identity, grandchild.identity); + assert.equal(independent.nativeId, "root-native"); + assert.equal(independent.corroborationIdentity, independent.identity); + assert.notEqual(independent.corroborationIdentity, child.corroborationIdentity); + assert.equal(grandchild.corroborationIdentity, child.corroborationIdentity); + for (const transcript of [child, grandchild]) { + assert.equal(transcript.parentSessionId, "root-native"); + assert.equal(transcript.corroborationNativeId, "root-native"); + assert.equal(transcript.corroborationStartedAt, Date.parse(old)); + assert.equal(transcript.interaction, NON_INTERACTIVE); + } + if (filtered) { + assert.equal(family, undefined); + } else { + assert.ok(family); + assert.equal(family.nativeId, independent.nativeId); + assert.notEqual(family.identity, independent.identity); + assert.equal(family.corroborationIdentity, family.identity); + assert.equal(child.corroborationIdentity, family.identity); + } + assertSingletonObserver([child, grandchild], child.corroborationIdentity); + const records = observerRecords([child, grandchild, independent]); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 1); + assert.equal(folded.instructions[0].harmSessions, 2); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + assert.equal(Object.keys(Object.values(ledger.entries)[0].sessions).length, 2); + const persisted = foldEvidence(records, { + minGapEvidence: 2, + gapObservations: ledgerGapObservations(ledger, "AGENTS.md"), + }); + assert.equal(persisted.gaps.length, 1); + assert.equal(persisted.instructions[0].harmSessions, 2); + }); + } + } +} + +for (const remoteOnly of [false, true]) { + for (const missingFirstRoot of [false, true]) { + for (const filtered of [false, true]) { + test(`copied descendants share an observer with a missing ${missingFirstRoot ? "retained" : "dropped"} ancestor (${remoteOnly ? "remote-only" : "local"}, ${filtered ? "filtered" : "all"})`, async () => { + const s = scenario(); + const now = Date.parse("2026-08-28T12:00:00.000Z"); + const old = "2026-08-27T00:00:00.000Z"; + const recent = "2026-08-28T11:00:00.000Z"; + const firstHome = remoteOnly ? s.remoteHome : s.localHome; + const firstCwd = remoteOnly ? s.remoteClone : s.repoRoot; + const secondHome = remoteOnly ? tmpdir("remote-other") : s.remoteHome; + const secondCwd = remoteOnly + ? initRepo(path.join(secondHome, "code", "demo"), `https://${REMOTE}.git`) + : s.remoteClone; + if (remoteOnly) s.hosts["mac-other"] = { home: secondHome }; + const rootName = "2026-08-27T00-00-00.000Z_root"; + const firstDirectory = path.join(firstHome, ".omp", "agent", "sessions", "-repo-demo"); + const secondDirectory = path.join(secondHome, ".omp", "agent", "sessions", "-repo-demo"); + const firstRootPath = path.join(firstDirectory, `${rootName}.jsonl`); + const secondRootPath = path.join(secondDirectory, `${rootName}.jsonl`); + const childPath = path.join(firstDirectory, rootName, "Child.jsonl"); + const copiedChildPath = path.join(secondDirectory, rootName, "Child.jsonl"); + const grandchildPath = path.join(secondDirectory, rootName, "Child", "Child.Grandchild.jsonl"); + const independentPath = path.join(firstHome, ".pi", "agent", "sessions", "-repo-demo", "split-root.jsonl"); + const write = (file, id, cwd, timestamp) => { + writeOmpSession(file, id, cwd, timestamp); + fs.utimesSync(file, Date.parse(timestamp) / 1000, Date.parse(timestamp) / 1000); + }; + write(independentPath, "root-native", firstCwd, recent); + write(firstRootPath, "root-native", firstCwd, old); + write(secondRootPath, "root-native", secondCwd, old); + fs.writeFileSync(missingFirstRoot ? firstRootPath : secondRootPath, "not a session header\n"); + write(childPath, "child-native", firstCwd, recent); + write(copiedChildPath, "child-native", secondCwd, recent); + write(grandchildPath, "grandchild-native", secondCwd, recent); + const result = await withRemotePiEnv(s, async () => { + const options = projectRun(s.repoRoot, { + discovery: { + hosts: remoteOnly ? ["mac-home", "mac-other"] : ["mac-home"], + harnesses: ["pi"], + since: filtered ? "1d" : "all", + includeOmp: true, + }, + }); + const discovered = await discoverTranscripts({ ...options, now }); + await closeSshMasters(discovered.remoteMasters); + return discovered; + }); + assert.equal(result.transcripts.length, !filtered && !missingFirstRoot ? 4 : 3); + assert.equal(result.perHost.at(-1).duplicates, !filtered && missingFirstRoot ? 2 : 1); + const independent = result.transcripts.find((transcript) => transcript.path === independentPath); + const child = result.transcripts.find((transcript) => transcript.nativeId === "child-native"); + const grandchild = result.transcripts.find((transcript) => transcript.nativeId === "grandchild-native"); + assert.ok(independent && child && grandchild); + assert.equal(child.path, childPath); + assert.equal(child.host, remoteOnly ? "mac-home" : null); + assert.equal(grandchild.path, grandchildPath); + assert.equal(grandchild.host, remoteOnly ? "mac-other" : "mac-home"); + assert.notEqual(child.identity, grandchild.identity); + assert.equal(child.parentSessionId, missingFirstRoot ? undefined : "root-native"); + assert.equal(grandchild.parentSessionId, missingFirstRoot ? "root-native" : "child-native"); + assert.equal(child.interactionSignals.source, "subagent"); + assert.equal(child.interaction, NON_INTERACTIVE); + assert.equal(grandchild.interaction, NON_INTERACTIVE); + const familyIdentity = missingFirstRoot ? child.identity : child.corroborationIdentity; + assert.equal(grandchild.corroborationIdentity, familyIdentity); + assert.equal(independent.corroborationIdentity, independent.identity); + assert.notEqual(independent.identity, familyIdentity); + if (missingFirstRoot) { + assert.equal(child.corroborationIdentity, child.identity); + assert.equal(child.corroborationNativeId, "child-native"); + } else { + assert.equal(child.corroborationNativeId, "root-native"); + const family = result.transcripts.find((transcript) => transcript.path === firstRootPath); + if (filtered) assert.equal(family, undefined); + else assert.equal(family.identity, familyIdentity); + } + assertSingletonObserver([child, grandchild], familyIdentity); + const records = observerRecords([child, grandchild, independent]); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 1); + assert.equal(folded.instructions[0].harmSessions, 2); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records); + assert.deepEqual( + Object.keys(Object.values(ledger.entries)[0].sessions).sort(), + [familyIdentity, independent.identity].sort(), + ); + const persisted = foldEvidence(records, { + minGapEvidence: 2, + gapObservations: ledgerGapObservations(ledger, "AGENTS.md"), + }); + assert.equal(persisted.gaps.length, 1); + assert.equal(persisted.instructions[0].harmSessions, 2); + }); + } + } +} + +test("filtered OMP roots still share one cross-host gap and harm observer", async () => { + const now = Date.parse("2026-08-28T12:00:00.000Z"); + const old = Date.parse("2026-08-27T00:00:00.000Z"); + const recent = "2026-08-28T11:00:00.000Z"; + for (const includeLocal of [true, false]) { + const s = scenario(); + const otherHome = tmpdir("remote-other"); + const otherClone = initRepo(path.join(otherHome, "code", "demo"), `https://${REMOTE}.git`); + s.hosts["mac-other"] = { home: otherHome }; + const rootName = "2026-08-27T00-00-00.000Z_root"; + const stores = [ + [s.localHome, s.repoRoot], + [s.remoteHome, s.remoteClone], + [otherHome, otherClone], + ].map(([home, cwd]) => ({ + directory: path.join(home, ".omp", "agent", "sessions", "-repo-demo"), + cwd, + })); + for (const store of stores) { + const rootPath = path.join(store.directory, `${rootName}.jsonl`); + writeOmpSession(rootPath, "root-native", store.cwd); + fs.utimesSync(rootPath, old / 1000, old / 1000); + } + const writeRecent = (file, id, cwd) => { + writeOmpSession(file, id, cwd, recent); + fs.utimesSync(file, Date.parse(recent) / 1000, Date.parse(recent) / 1000); + }; + if (includeLocal) { + writeRecent(path.join(stores[0].directory, rootName, "Local.jsonl"), "local-child", s.repoRoot); + } + writeRecent(path.join(stores[1].directory, rootName, "Remote.jsonl"), "remote-child", s.remoteClone); + writeRecent(path.join(stores[2].directory, rootName, "Other.jsonl"), "other-child", otherClone); + writeRecent( + path.join(stores[2].directory, rootName, "Other", "Other.Grandchild.jsonl"), + "other-grandchild", + otherClone, + ); + const discover = () => + withRemotePiEnv(s, async () => { + const options = projectRun(s.repoRoot, { + discovery: { hosts: ["mac-home", "mac-other"], harnesses: ["pi"], since: "1d", includeOmp: true }, + }); + const result = await discoverTranscripts({ ...options, now }); + await closeSshMasters(result.remoteMasters); + return result; + }); + const result = await discover(); + assert.equal(result.cutoffMs, now - 24 * 60 * 60 * 1000); + assert.deepEqual( + result.transcripts.map((transcript) => transcript.nativeId).sort(), + [...(includeLocal ? ["local-child"] : []), "other-child", "other-grandchild", "remote-child"].sort(), + "old roots are excluded while every distinct recent descendant is retained", + ); + assert.ok(result.perHost.every((host) => host.duplicates === 0)); + assert.equal(new Set(result.transcripts.map((transcript) => transcript.identity)).size, result.transcripts.length); + assert.equal(new Set(result.transcripts.map((transcript) => transcript.corroborationIdentity)).size, 1); + for (const transcript of result.transcripts) { + assert.equal(transcript.parentSessionId, "root-native"); + assert.equal(transcript.corroborationNativeId, "root-native"); + assert.equal(transcript.corroborationStartedAt, old); + assert.equal(transcript.interaction, NON_INTERACTIVE); + } + const recordsFor = (transcripts) => + transcripts.map((transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + transcript, + negative: [{ instruction: "db-rule", class: "harm", quote: "following the rule caused damage" }], + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived the schema", + quote: "read the schema", + recurrenceRisk: "high", + domain: "project", + }, + ], + })); + const records = recordsFor(result.transcripts); + const folded = foldEvidence(records, { minGapEvidence: 2 }); + assert.equal(folded.gaps.length, 0); + assert.equal(folded.totals.droppedGapSingletons, 1); + assert.equal(folded.instructions[0].harmSessions, 1); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, records, { now: new Date(now) }); + assert.equal(Object.keys(Object.values(ledger.entries)[0].sessions).length, 1); + const persisted = foldEvidence(records, { + minGapEvidence: 2, + gapObservations: ledgerGapObservations(ledger, "AGENTS.md"), + }); + assert.equal(persisted.gaps.length, 0); + assert.equal(persisted.instructions[0].harmSessions, 1); + + if (includeLocal) { + const splitPath = path.join(s.localHome, ".pi", "agent", "sessions", "-repo-demo", "split-root.jsonl"); + writeRecent(splitPath, "root-native", s.repoRoot); + const split = await discover(); + const ordinary = split.transcripts.find((transcript) => transcript.path === splitPath); + const localChild = split.transcripts.find((transcript) => transcript.nativeId === "local-child"); + assert.equal(ordinary.interaction, INTERACTIVE); + assert.equal(ordinary.parentSessionId, undefined); + assert.equal(ordinary.corroborationIdentity, ordinary.identity); + assert.notEqual(ordinary.corroborationIdentity, localChild.corroborationIdentity); + assert.equal(new Set(split.transcripts.map((transcript) => transcript.corroborationIdentity)).size, 2); + const splitFolded = foldEvidence(recordsFor(split.transcripts), { minGapEvidence: 2 }); + assert.equal(splitFolded.gaps.length, 1); + assert.equal(splitFolded.instructions[0].harmSessions, 2); + } + } +}); + +test("remote nested OMP automation is non-interactive without readable ancestor provenance", async () => { + const s = scenario(); + const sessionRoot = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); + writeOmpSession(path.join(sessionRoot, "root", "Child.jsonl"), "child-native", s.remoteClone); + writeOmpSession( + path.join(sessionRoot, "other-root", "Missing", "Missing.Grandchild.jsonl"), + "grandchild-native", + s.remoteClone, + ); + fs.writeFileSync(path.join(sessionRoot, "root.jsonl"), "not a session header\n"); + const result = await withRemotePiEnv(s, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); + assert.equal(result.transcripts.length, 2); + for (const transcript of result.transcripts) { + assert.equal(transcript.host, "mac-home"); + assert.equal(transcript.interactionSignals.source, "subagent"); + assert.equal(transcript.interaction, NON_INTERACTIVE); + assert.equal(transcript.parentSessionId, undefined); + assert.equal(transcript.corroborationIdentity, transcript.identity); + assert.equal(transcript.corroborationNativeId, transcript.nativeId); + assert.equal(transcript.corroborationStartedAt, transcript.startedAt); + } + writeOmpSession(path.join(sessionRoot, "root.jsonl"), "root-native", s.remoteClone); + writeOmpSession(path.join(sessionRoot, "other-root.jsonl"), "other-root-native", s.remoteClone); + const refreshed = await withRemotePiEnv(s, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); + for (const [childId, rootId] of [ + ["child-native", "root-native"], + ["grandchild-native", "other-root-native"], + ]) { + const child = refreshed.transcripts.find((transcript) => transcript.nativeId === childId); + const root = refreshed.transcripts.find((transcript) => transcript.nativeId === rootId); + assert.equal(child.parentSessionId, rootId); + assert.equal(child.corroborationIdentity, root.identity); + assert.equal(child.interaction, NON_INTERACTIVE); + assert.equal(root.interaction, INTERACTIVE); + } +}); + test("host collection has plain progress without duplicating live progress", async () => { const plain = scenario(); const lines = [];