From 62f5d3e5416cfd451d4dfa179bb60b93dba0349c Mon Sep 17 00:00:00 2001 From: mdc2122 Date: Tue, 15 Sep 2026 22:22:06 -0500 Subject: [PATCH 01/17] feat(discovery): discover omp session transcripts in the pi adapter omp (Oh My Pi) writes the same version:3 JSONL session format as pi under ~/.omp/agent/sessions/, but prepends a fixed-width {type:"title"} record so the {type:"session", cwd} entry is line 2, and nests subagent transcripts one level deeper at //.jsonl. - Add ~/.omp/agent/sessions as a default nested store root (omp honors PI_CODING_AGENT_DIR, so an env redirect still dedupes against it). - classify() scans the first head lines for the session entry instead of requiring it on line 1. - enumerate() descends one extra level inside nested roots. - read() accepts model_change.model as a fallback for modelId. --- src/discovery/adapters/pi.js | 51 +++++++++++++++++++++------------ test/adapters.test.js | 34 +++++++++++++++++++++- test/fixtures/omp-session.jsonl | 6 ++++ 3 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 test/fixtures/omp-session.jsonl diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 82e5e1fc..1b841c36 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -16,12 +16,17 @@ import { /** * Pi writes standalone sessions under - * `~/.pi/agent/sessions//_.jsonl`. BB's Pi bridge writes the - * same JSONL shape directly under `/pi-bridge-sessions/`. + * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the + * same JSONL shape under `~/.omp/agent/sessions/` and honors `PI_CODING_AGENT_DIR`, but + * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` + * entry is line 2 there. omp also writes subagent transcripts one level deeper, at + * `//.jsonl`. BB's Pi bridge writes the same JSONL shape + * directly under `/pi-bridge-sessions/`. * - * Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in + * Entries form a parent/child tree but arrive in * order, so a linear read is faithful. `model_change` / `thinking_level_change` records - * give the model actually used. No remote is recorded - dead worktrees reach tier 3 only. + * give the model actually used (`modelId` on pi, `model` on omp). No remote is + * recorded - dead worktrees reach tier 3 only. */ export const name = "pi"; @@ -49,6 +54,7 @@ function realpathOrResolve(value) { function storeSpecs() { const specs = [ { path: storeRoot(), direct: false, nested: true }, + { path: home(".omp", "agent", "sessions"), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); @@ -84,7 +90,13 @@ export function enumerate() { for (const spec of storeSpecs()) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested ? listDirs(spec.path).flatMap((dir) => listFiles(dir, ".jsonl")) : []), + ...(spec.nested + ? listDirs(spec.path).flatMap((dir) => [ + ...listFiles(dir, ".jsonl"), + // omp nests subagent transcripts one level below the session files. + ...listDirs(dir).flatMap((sub) => listFiles(sub, ".jsonl")), + ]) + : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -99,18 +111,21 @@ export function enumerate() { } export function classify(candidate) { - const [first] = readHeadLines(candidate.path, 1); - const entry = first && parseJsonLine(first); - if (!entry || entry.type !== "session" || !entry.cwd) return null; - return { - id: entry.id || path.basename(candidate.path, ".jsonl"), - cwd: entry.cwd, - gitBranch: null, - remotes: [], - startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, - model: null, - interactionSignals: emptyInteractionSignals(), - }; + // omp prepends a fixed-width title record, so the session entry is not always line 1. + for (const line of readHeadLines(candidate.path, 8)) { + const entry = parseJsonLine(line); + if (!entry || entry.type !== "session" || !entry.cwd) continue; + return { + id: entry.id || path.basename(candidate.path, ".jsonl"), + cwd: entry.cwd, + gitBranch: null, + remotes: [], + startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, + model: null, + interactionSignals: emptyInteractionSignals(), + }; + } + return null; } export function read(ref) { @@ -120,7 +135,7 @@ export function read(ref) { for (const entry of entries) { if (entry.type === "model_change") { - model = entry.modelId || model; + model = entry.modelId || entry.model || model; continue; } if (entry.type !== "message" || !entry.message) continue; diff --git a/test/adapters.test.js b/test/adapters.test.js index d95bacd9..bf60f4bb 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -151,6 +151,28 @@ test("pi adapter reads the session header and drops thinking blocks", () => { assert.equal(toolCall.result, "nothing to commit"); }); +test("pi adapter classifies omp sessions past the title record and reads model", () => { + const file = path.join(FIXTURES, "omp-session.jsonl"); + const descriptor = pi.classify(candidateFor(file)); + assert.equal(descriptor.id, "omp-5678"); + assert.equal(descriptor.cwd, "/repo/demo"); + + const { events, model } = pi.read({ path: file }); + assert.equal(model, "cursor/composer-2.5", "omp model_change carries model, not modelId"); + const [toolCall] = tools(events); + assert.equal(toolCall.name, "bash"); + assert.equal(toolCall.result, "nothing to commit"); +}); + +function writeOmpSession(file, { id, cwd }) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "", updatedAt: "2026-08-27T00:00:00.000Z", pad: " " })}\n` + + `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd })}\n`, + ); +} + function writePiSession(file, { id, cwd }) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( @@ -200,6 +222,14 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl id: "standalone", cwd: "/repo/demo", }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone.jsonl"), { + id: "omp-standalone", + cwd: "/repo/demo", + }); + writeOmpSession(path.join(fakeHome, ".omp", "agent", "sessions", "-repo-demo", "omp-standalone", "Subagent.jsonl"), { + id: "omp-subagent", + cwd: "/repo/demo", + }); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -233,8 +263,10 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "custom-session.jsonl", "default-bb.jsonl", "direct-override.jsonl", + "omp-standalone.jsonl", "standalone.jsonl", - ], + "Subagent.jsonl", + ].sort(), ); }); diff --git a/test/fixtures/omp-session.jsonl b/test/fixtures/omp-session.jsonl new file mode 100644 index 00000000..7ee32648 --- /dev/null +++ b/test/fixtures/omp-session.jsonl @@ -0,0 +1,6 @@ +{"type":"title","v":1,"title":"","updatedAt":"2026-08-03T08:00:00.000Z","pad":" "} +{"type":"session","version":3,"id":"omp-5678","timestamp":"2026-08-03T08:00:00.000Z","cwd":"/repo/demo"} +{"type":"model_change","id":"m1","parentId":null,"timestamp":"2026-08-03T08:00:00.100Z","model":"cursor/composer-2.5","resolvedModelIsFallback":false} +{"type":"message","id":"e1","parentId":"m1","timestamp":"2026-08-03T08:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Add the changelog entry."}]}} +{"type":"message","id":"e2","parentId":"e1","timestamp":"2026-08-03T08:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"internal reasoning that must be dropped"},{"type":"text","text":"Editing CHANGELOG.md."},{"type":"toolCall","id":"tc1","name":"bash","arguments":{"command":"git status"}}]}} +{"type":"message","id":"e3","parentId":"e2","timestamp":"2026-08-03T08:00:03.000Z","message":{"role":"toolResult","toolCallId":"tc1","content":[{"type":"text","text":"nothing to commit"}]}} From bb100f43dfa7cf7715b03638d2f357240542e085 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 00:41:29 -0500 Subject: [PATCH 02/17] fix(discovery): share OMP corroboration across subagents --- README.md | 16 ++-- src/acpx.js | 3 +- src/analyze.js | 6 +- src/commands/propose.js | 51 +++++++---- src/discovery/adapters/pi.js | 87 +++++++++++++++--- src/discovery/index.js | 33 ++++++- src/discovery/remote/probe.js | 9 +- src/fold.js | 12 ++- src/gap-ledger.js | 100 ++++++++++++++++++-- src/interaction.js | 1 + src/transcript.js | 5 + test/adapters.test.js | 55 +++++++++++ test/analyze-reuse.test.js | 112 +++++++++++++++++++++++ test/fold.test.js | 68 ++++++++++++++ test/gap-ledger.test.js | 168 ++++++++++++++++++++++++++++++++++ test/interaction.test.js | 103 +++++++++++++++++++++ test/remote-discovery.test.js | 50 ++++++++++ 17 files changed, 818 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 799a0911..e839a9a1 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,10 @@ sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR` set in backpass's environment. When roots overlap, backpass scans every applicable layout and reads each JSONL file once. +OMP nests subagent JSONL files below each parent session. Backpass analyzes each file +separately, but uses the parent session as their shared corroboration source; a parent and +its subagents cannot count as independent sessions. + OpenCode collection reads both store layouts: OpenCode 1.x (`session`, `message`, `part`) and OpenCode 2.x (`session_v2`, `session_message`). For 2.x, session activity uses the later of the session's update time and its newest message's update time. An upgraded store keeps its 1.x tables beside the copies in `session_v2`, so a session found in both is read from `session_v2`. @@ -289,12 +293,12 @@ OpenCode sessions with no recorded messages, such as unused agent probes, are no Every remaining session is labelled **interactive** or **non-interactive** (`src/interaction.js`). Codex `codex exec` / `originator: codex_exec`, Claude SDK, GitHub, action, and CI -entrypoints, OpenCode child sessions (`parent_id`), and a cwd with a `.no-mistakes` path -segment are non-interactive. Hermes gateway, cron, and WhatsApp sessions are classified the -same way if they leak past collection's source filter. A no-mistakes pipeline run is just one -kind of non-interactive session, not its own category. Missing harness metadata defaults to -interactive. `backpass scan`, the proposal, and apply all print the mix so relevance is never -silently computed against a robot-skewed pool. +entrypoints, OpenCode child sessions (`parent_id`), OMP subagent transcripts, and a cwd +with a `.no-mistakes` path segment are non-interactive. Hermes gateway, cron, and WhatsApp +sessions are classified the same way if they leak past collection's source filter. A +no-mistakes pipeline run is just one kind of non-interactive session, not its own category. +Missing harness metadata defaults to interactive. `backpass scan`, the proposal, and apply +all print the mix so relevance is never silently computed against a robot-skewed pool. ```sh backpass scan --since 7d --strict diff --git a/src/acpx.js b/src/acpx.js index 50197339..9102e1fd 100644 --- a/src/acpx.js +++ b/src/acpx.js @@ -906,8 +906,9 @@ function recoverPiUsage({ promptFile, cwd, startedAt }) { .filter((c) => c.mtimeMs >= since) .sort((a, b) => b.mtimeMs - a.mtimeMs); + const scanContext = piStore.createScanContext(); for (const candidate of candidates) { - const descriptor = piStore.classify(candidate); + const descriptor = piStore.classify(candidate, { scanContext }); if (!descriptor || !wanted.has(descriptor.cwd)) continue; const entries = readJsonl(candidate.path); const firstUser = entries.find((e) => e.type === "message" && e.message?.role === "user"); diff --git a/src/analyze.js b/src/analyze.js index a45be6f6..7bbcf785 100644 --- a/src/analyze.js +++ b/src/analyze.js @@ -13,7 +13,7 @@ import { renderOpenGapIndex } from "./gap-ledger.js"; import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js"; import { emitProgress } from "./progress.js"; import { UserError, color, info, warn } from "./logger.js"; -import { transcriptIdentity } from "./transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "./transcript.js"; /** * Stage 1 of the pipeline (design section 3): one cheap model call per transcript, @@ -410,6 +410,10 @@ export async function analyzeTranscripts({ harness: transcript.harness, id: transcript.id, identity: transcriptIdentity(transcript), + parentSessionId: transcript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(transcript), + corroborationNativeId: transcript.corroborationNativeId || null, + corroborationStartedAt: transcript.corroborationStartedAt ?? null, path: transcript.path, mtimeMs: transcript.mtimeMs, bytes: transcript.bytes, diff --git a/src/commands/propose.js b/src/commands/propose.js index 9d59b56f..fb0ba771 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -1,6 +1,11 @@ import { consolidateGapLedger } from "../consolidate.js"; import { foldEvidence } from "../fold.js"; -import { ledgerGapObservations, pruneGapLedger, recordGapObservations } from "../gap-ledger.js"; +import { + ledgerGapObservations, + normalizeGapLedgerSessions, + pruneGapLedger, + recordGapObservations, +} from "../gap-ledger.js"; import { synthesizeProposal } from "../synthesize.js"; import { ProposalViolation } from "../proposal.js"; import { formatCorpusMix, INTERACTIVE, NON_INTERACTIVE } from "../interaction.js"; @@ -13,7 +18,7 @@ import { printUsage } from "./usage.js"; import { closeRemoteDiscovery, discoverForRun } from "./scan.js"; import { capTranscripts } from "../sample.js"; import { isEvidenceFresh } from "../state.js"; -import { transcriptIdentity } from "../transcript.js"; +import { corroborationIdentityOf, transcriptIdentity } from "../transcript.js"; import { pruneHostCache } from "../discovery/cache.js"; /** @@ -23,17 +28,16 @@ import { pruneHostCache } from "../discovery/cache.js"; * the evidence files that fed an expired sighting are still on disk and would re-add it), * then cluster from the ledger. * - * Evidence is filtered to selected transcript identities, the current memory hash and - * analysis-index cache key, and a valid interaction category. Reanalysis rewrites a - * transcript's evidence when an input changes, but records outside this run's window or - * cap remain on disk. Folding those records would inflate `analyzedSessions` beyond the - * sampled corpus or score positional instruction aliases against an index they never saw. - * Legacy records stay excluded until ordinary discovery and analysis backfill them. + * Evidence is filtered to selected transcript identities, the current memory hash, and + * the analysis-index cache key. Current discovery metadata is overlaid on persisted + * records for folding, restoring observer and interaction details for legacy evidence + * without admitting records outside this run's sample. Folding leftover records would + * inflate `analyzedSessions` beyond the sampled corpus or score positional instruction + * aliases against an index they never saw. */ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], transcripts = [], { route = null } = {}) { const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config; const selectedByIdentity = new Map(transcripts.map((transcript) => [transcriptIdentity(transcript), transcript])); - const selected = new Set(selectedByIdentity.keys()); const evidence = state.listEvidence(); const identitiesByLegacyId = new Map(); for (const record of evidence) { @@ -42,25 +46,32 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans if (!identitiesByLegacyId.has(legacyId)) identitiesByLegacyId.set(legacyId, new Set()); identitiesByLegacyId.get(legacyId).add(transcriptIdentity(record.transcript)); } - const selectedGapSessions = new Set(selected); + const selectedGapSessions = new Set(selectedByIdentity.keys()); for (const transcript of transcripts) { + selectedGapSessions.add(corroborationIdentityOf(transcript)); const identities = identitiesByLegacyId.get(transcript.id); if (identities?.size === 1 && identities.has(transcriptIdentity(transcript))) { selectedGapSessions.add(transcript.id); } } - const relevant = evidence.filter((e) => { - const currentTranscript = selectedByIdentity.get(transcriptIdentity(e.transcript)); - return ( - e.memoryPath === memoryFile.path && - e.memoryHash === memoryHash && - (e.transcript?.interaction === INTERACTIVE || e.transcript?.interaction === NON_INTERACTIVE) && - currentTranscript && - isEvidenceFresh(e, currentTranscript, memoryHash) - ); - }); + const relevant = []; + for (const record of evidence) { + const currentTranscript = selectedByIdentity.get(transcriptIdentity(record.transcript)); + if ( + record.memoryPath !== memoryFile.path || + record.memoryHash !== memoryHash || + !currentTranscript || + !isEvidenceFresh(record, currentTranscript, memoryHash) + ) { + continue; + } + const transcript = { ...record.transcript, ...currentTranscript }; + if (transcript.interaction !== INTERACTIVE && transcript.interaction !== NON_INTERACTIVE) continue; + relevant.push({ ...record, transcript }); + } const ledger = state.readGapLedger(); + normalizeGapLedgerSessions(ledger, transcripts); recordGapObservations(ledger, relevant, { skills }); // Consolidate after recording, so the pass sees this run's sightings too: two // sessions coining the same brand-new gap in one parallel fan-out can only line up diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 1b841c36..a8b4c2df 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -30,6 +30,7 @@ import { */ export const name = "pi"; +export const cacheVersion = 3; export function storeRoot() { return home(".pi", "agent", "sessions"); @@ -110,22 +111,78 @@ export function enumerate() { return out; } -export function classify(candidate) { - // omp prepends a fixed-width title record, so the session entry is not always line 1. - for (const line of readHeadLines(candidate.path, 8)) { - const entry = parseJsonLine(line); - if (!entry || entry.type !== "session" || !entry.cwd) continue; - return { - id: entry.id || path.basename(candidate.path, ".jsonl"), - cwd: entry.cwd, - gitBranch: null, - remotes: [], - startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, - model: null, - interactionSignals: emptyInteractionSignals(), - }; +export function createScanContext() { + return { parentHeaders: new Map() }; +} + +function readSessionHeader(file) { + const [firstLine, secondLine] = readHeadLines(file, 2); + const first = parseJsonLine(firstLine); + return first?.type === "session" ? first : first?.type === "title" ? parseJsonLine(secondLine) : null; +} + +function readParentSession(parentPath, scanContext) { + const cache = scanContext?.parentHeaders; + if (cache?.has(parentPath)) return cache.get(parentPath); + const stat = statOrNull(parentPath); + const entry = stat?.isFile() ? readSessionHeader(parentPath) : null; + const result = + entry?.type === "session" + ? { + entry, + stat, + fingerprint: JSON.stringify([ + stat.dev, + stat.ino, + stat.mtimeMs, + stat.ctimeMs, + stat.size, + entry.id ?? null, + entry.cwd ?? null, + entry.timestamp ?? null, + ]), + } + : null; + cache?.set(parentPath, result); + return result; +} + +function parentSessionPathFor(candidatePath) { + const sessionDir = path.dirname(candidatePath); + return path.join(path.dirname(sessionDir), `${path.basename(sessionDir)}.jsonl`); +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function cacheDependency(candidate, options = {}) { + return readParentSession(parentSessionPathFor(candidate.path), options.scanContext)?.fingerprint ?? null; +} + +/** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ +export function classify(candidate, options = {}) { + const { scanContext } = options; + const entry = readSessionHeader(candidate.path); + if (!entry || entry.type !== "session" || !entry.cwd) return null; + + const descriptor = { + id: entry.id || path.basename(candidate.path, ".jsonl"), + cwd: entry.cwd, + gitBranch: null, + remotes: [], + startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs, + model: null, + interactionSignals: emptyInteractionSignals(), + }; + + const parentPath = parentSessionPathFor(candidate.path); + const parentInfo = readParentSession(parentPath, scanContext); + const parent = parentInfo?.entry; + if (parent?.type === "session" && parent.cwd === entry.cwd) { + descriptor.parentSessionId = parent.id || path.basename(parentPath, ".jsonl"); + descriptor.parentSessionPath = parentPath; + descriptor.parentSessionStartedAt = parent.timestamp ? Date.parse(parent.timestamp) : parentInfo.stat.mtimeMs; } - return null; + + return descriptor; } export function read(ref) { diff --git a/src/discovery/index.js b/src/discovery/index.js index 87b62698..6b467090 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -316,8 +316,10 @@ function discoverFiles( { repo, config, cutoffMs, strict, stats, cache, markDirty, associateFn, stateDir, userFilter }, ) { const candidates = adapter.enumerate({ cutoffMs, repo, config }); + const scanContext = adapter.createScanContext?.(); const out = []; + const hasCacheDependency = typeof adapter.cacheDependency === "function"; for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; stats.scanned += 1; @@ -334,19 +336,31 @@ function discoverFiles( const cacheKey = `${adapter.name}:${candidate.key}`; const cached = cache.entries[cacheKey]; + const cacheDependency = hasCacheDependency + ? adapter.cacheDependency(candidate, { repo, config, scanContext }) + : undefined; let descriptor; if ( cached && + cached.cacheVersion === adapter.cacheVersion && cached.mtimeMs === candidate.mtimeMs && cached.bytes === candidate.bytes && + (!hasCacheDependency || cached.cacheDependency === cacheDependency) && hasInteractionSignals(cached.descriptor) ) { stats.cached += 1; descriptor = cached.descriptor; } else { - descriptor = adapter.classify(candidate, { repo, config }) || null; - cache.entries[cacheKey] = { mtimeMs: candidate.mtimeMs, bytes: candidate.bytes, descriptor }; + descriptor = adapter.classify(candidate, { repo, config, scanContext }) || null; + const cacheEntry = { + cacheVersion: adapter.cacheVersion, + mtimeMs: candidate.mtimeMs, + bytes: candidate.bytes, + descriptor, + }; + if (hasCacheDependency) cacheEntry.cacheDependency = cacheDependency; + cache.entries[cacheKey] = cacheEntry; markDirty(); } @@ -408,6 +422,21 @@ function toTranscript(adapter, row, association, id, { host = null, remote = nul remote, }; transcript.identity = transcriptIdentity(transcript); + if (row.parentSessionId && row.parentSessionPath) { + transcript.parentSessionId = row.parentSessionId; + transcript.corroborationIdentity = transcriptIdentity({ + ...transcript, + identity: null, + nativeId: row.parentSessionId, + path: row.parentSessionPath, + }); + transcript.corroborationNativeId = row.parentSessionId; + transcript.corroborationStartedAt = row.parentSessionStartedAt ?? transcript.startedAt; + } else { + transcript.corroborationIdentity = transcript.identity; + transcript.corroborationNativeId = id; + transcript.corroborationStartedAt = transcript.startedAt; + } transcript.interaction = classifyInteraction(transcript); return transcript; } diff --git a/src/discovery/remote/probe.js b/src/discovery/remote/probe.js index 1d55f98f..145460bd 100644 --- a/src/discovery/remote/probe.js +++ b/src/discovery/remote/probe.js @@ -89,6 +89,9 @@ async function descriptorFrom(adapter, row, id) { remotes: Array.isArray(row.remotes) ? row.remotes : [], title: row.title || null, startedAt: row.startedAt || null, + parentSessionId: row.parentSessionId, + parentSessionPath: row.parentSessionPath, + parentSessionStartedAt: row.parentSessionStartedAt, mtimeMs: row.mtimeMs || 0, bytes: row.bytes || 0, contentSignature, @@ -124,10 +127,12 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } - for (const candidate of adapter.enumerate({ cutoffMs })) { + const candidates = adapter.enumerate({ cutoffMs }); + const scanContext = adapter.createScanContext?.(); + for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; stats.scanned += 1; - const classified = adapter.classify(candidate); + const classified = adapter.classify(candidate, { scanContext }); if (!classified) continue; stats.classified += 1; const merged = { ...candidate, ...classified }; diff --git a/src/fold.js b/src/fold.js index 768c2823..2ae0152e 100644 --- a/src/fold.js +++ b/src/fold.js @@ -12,6 +12,7 @@ import { normalizeSourceLabel, } from "./gap-ledger.js"; import { crossSurfaceDuplicates } from "./overlap.js"; +import { corroborationIdentityOf } from "./transcript.js"; /** * Stage 2 of the pipeline (design section 3): fold per-transcript evidence into one @@ -107,7 +108,7 @@ export function foldEvidence( const issuedSources = disambiguateSourceLabels([ ...usable.map((record) => ({ source: gapSource(record.transcript), - identity: record.transcript.identity || record.transcript.id, + identity: corroborationIdentityOf(record.transcript), })), ...persistedObservations.map((observation) => ({ source: observation?.source, @@ -117,6 +118,8 @@ export function foldEvidence( const recordSources = issuedSources.slice(0, usable.length); const observationSources = issuedSources.slice(usable.length); for (const [index, record] of usable.entries()) { + const sessionIdentity = record.transcript.identity || record.transcript.id; + const corroborationIdentity = corroborationIdentityOf(record.transcript); if (record.usedRawTranscript) usedRawCount += 1; const source = recordSources[index]; sources.add(source); @@ -126,7 +129,6 @@ export function foldEvidence( for (const item of record[polarity] || []) { const entry = touch(item.instruction); entry[polarity] += 1; - const sessionIdentity = record.transcript.identity || record.transcript.id; const category = classifyInteraction(record.transcript); entry.sessions.add(sessionIdentity); entry.sessionsByInteraction[category].add(sessionIdentity); @@ -138,9 +140,9 @@ export function foldEvidence( // `class` is what a negative means (harm vs non-compliance vs irrelevant); // `harmSessions` is what the removal-evidence floor counts. A record from // before the class existed carries none and never counts as harm. - if (polarity === "negative" && item.class === "harm") entry.harmSessions.add(sessionIdentity); + if (polarity === "negative" && item.class === "harm") entry.harmSessions.add(corroborationIdentity); if (polarity === "negative" && item.class === "non-compliance") { - entry.nonComplianceSessions.add(sessionIdentity); + entry.nonComplianceSessions.add(corroborationIdentity); } entry.quotes.push({ polarity, @@ -162,7 +164,7 @@ export function foldEvidence( quote: gap.quote, recurrenceRisk: gap.recurrenceRisk, source, - sessionId: record.transcript.identity || record.transcript.id, + sessionId: corroborationIdentity, domain: gap.domain === "orchestration" ? "orchestration" : "project", project: record.transcript.project || null, projectRoot: record.transcript.projectRoot || null, diff --git a/src/gap-ledger.js b/src/gap-ledger.js index e405c303..e6171746 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -1,6 +1,7 @@ import { parseMemoryUnits, similarity } from "./memory.js"; import { parseSince } from "./config.js"; import { sha256 } from "./state.js"; +import { corroborationIdentityOf } from "./transcript.js"; /** * Durable gap corroboration across runs (`.backpass/gap-ledger.json`). @@ -70,9 +71,11 @@ export function emptyGapLedger() { * cross-machine corroboration actually is: two machines hitting one gap, named. */ export function gapSource(transcript = {}) { - const date = transcript.startedAt ? new Date(transcript.startedAt).toISOString().slice(0, 10) : "unknown date"; + const startedAt = transcript.corroborationStartedAt ?? transcript.startedAt; + const date = startedAt ? new Date(startedAt).toISOString().slice(0, 10) : "unknown date"; const host = transcript.host ? ` · ${transcript.host}` : ""; - return `${transcript.harness} · ${sessionSourceId(transcript)} · ${date}${host}`; + const sourceId = transcript.corroborationNativeId || sessionSourceId(transcript); + return `${transcript.harness} · ${sourceId} · ${date}${host}`; } export function sessionSourceId(transcript = {}) { @@ -162,6 +165,12 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } +function sessionIdentityAliases(transcript, sessionIdentity) { + return [...new Set([transcript.identity, transcript.id])].filter( + (identity) => identity && identity !== sessionIdentity, + ); +} + /** * Fold this run's evidence into the ledger. One observation per (gap, session); a * session seen again replaces its own observation and keeps its first-seen timestamp. @@ -175,8 +184,8 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { for (const record of evidenceRecords) { if (!record || record.status !== "ok" || !record.memoryPath) continue; const transcript = record.transcript || {}; - const sessionIdentity = transcript.identity || transcript.id; - if (!sessionIdentity) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + if (!(transcript.corroborationIdentity || transcript.identity || transcript.id)) continue; for (const gap of record.gaps || []) { if (!gap || !gap.proposedInstruction) continue; // A citation from the analysis turn wins over word overlap: the model saw both @@ -206,14 +215,15 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const identityPrior = entry.sessions[sessionIdentity]; - const aliasPrior = transcript.id && transcript.id !== sessionIdentity ? entry.sessions[transcript.id] : null; - const priors = [identityPrior, aliasPrior].filter(Boolean); + const aliases = sessionIdentityAliases(transcript, sessionIdentity); + const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + Boolean, + ); const firstObservedAt = priors .map((observation) => observation.firstObservedAt || observation.observedAt) .filter((value) => Number.isFinite(Date.parse(value))) .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; - if (aliasPrior) delete entry.sessions[transcript.id]; + for (const alias of aliases) delete entry.sessions[alias]; const coveredBySkill = gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; const phrasings = [ @@ -228,7 +238,10 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { firstObservedAt: firstObservedAt || observedAt, observedAt, sessionStartedAt: - transcript.startedAt ?? identityPrior?.sessionStartedAt ?? aliasPrior?.sessionStartedAt ?? null, + transcript.corroborationStartedAt ?? + transcript.startedAt ?? + priors.find((observation) => observation.sessionStartedAt)?.sessionStartedAt ?? + null, memoryHash: record.memoryHash || null, source: gapSource(transcript), mistake: gap.mistake, @@ -248,6 +261,75 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } +/** Re-key selected sessions in old ledgers when related transcript files now share an identity. */ +export function normalizeGapLedgerSessions(ledger, transcripts) { + const selections = []; + const selectionsByAlias = new Map(); + + for (const transcript of transcripts) { + if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; + const sessionIdentity = corroborationIdentityOf(transcript); + const aliases = sessionIdentityAliases(transcript, sessionIdentity); + if (!aliases.length) continue; + + const index = selections.length; + selections.push({ transcript, sessionIdentity, aliases }); + for (const alias of aliases) { + let indexes = selectionsByAlias.get(alias); + if (!indexes) selectionsByAlias.set(alias, (indexes = [])); + indexes.push(index); + } + } + + if (!selections.length) return; + for (const entry of Object.values(ledger.entries)) { + const pending = new Set(); + for (const identity of Object.keys(entry.sessions)) { + const indexes = selectionsByAlias.get(identity); + if (indexes) for (const index of indexes) pending.add(index); + } + + for (let index = 0; index < selections.length; index += 1) { + if (!pending.has(index)) continue; + const { transcript, sessionIdentity, aliases } = selections[index]; + if (!aliases.some((identity) => entry.sessions[identity])) continue; + const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + Boolean, + ); + const current = entry.sessions[sessionIdentity] || priors[0]; + const firstObservedAt = priors + .map((observation) => observation.firstObservedAt || observation.observedAt) + .filter((value) => Number.isFinite(Date.parse(value))) + .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; + const coveredBySkill = priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + const project = current.project || priors.find((observation) => observation.project)?.project; + const projectRoot = current.projectRoot || priors.find((observation) => observation.projectRoot)?.projectRoot; + + entry.sessions[sessionIdentity] = { + ...current, + ...(firstObservedAt ? { firstObservedAt } : {}), + sessionStartedAt: transcript.corroborationStartedAt ?? transcript.startedAt ?? current.sessionStartedAt ?? null, + source: gapSource(transcript), + phrasings: [ + ...new Set([ + entry.proposedInstruction, + ...priors.flatMap( + (observation) => observation.phrasings || [observation.proposedInstruction].filter(Boolean), + ), + ]), + ].filter(Boolean), + domain: priors.some((observation) => observation.domain !== "orchestration") ? "project" : "orchestration", + ...(coveredBySkill ? { coveredBySkill } : {}), + ...(project ? { project } : {}), + ...(projectRoot ? { projectRoot } : {}), + }; + for (const alias of aliases) delete entry.sessions[alias]; + + const next = selectionsByAlias.get(sessionIdentity); + if (next) for (const nextIndex of next) if (nextIndex > index) pending.add(nextIndex); + } + } +} /** * Retire observations that no longer count: sightings first seen more than `maxAge` ago diff --git a/src/interaction.js b/src/interaction.js index 020f1fa8..bcdde1bb 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -60,6 +60,7 @@ function claudeEntrypointIsNonInteractive(entrypoint) { * Explicit `transcript.interaction` is trusted when it is already one of the two labels. */ export function classifyInteraction(transcript) { + if (transcript?.parentSessionId) return NON_INTERACTIVE; const stamped = transcript?.interaction; if (stamped === INTERACTIVE || stamped === NON_INTERACTIVE) return stamped; diff --git a/src/transcript.js b/src/transcript.js index 32dc519e..d39a7890 100644 --- a/src/transcript.js +++ b/src/transcript.js @@ -37,3 +37,8 @@ export function transcriptIdentity(transcript) { ) .digest("hex"); } + +/** Shared observer identity for related transcript files such as OMP subagents. */ +export function corroborationIdentityOf(transcript) { + return transcript?.corroborationIdentity || transcriptIdentity(transcript); +} diff --git a/test/adapters.test.js b/test/adapters.test.js index bf60f4bb..1930aa3d 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -163,7 +163,62 @@ test("pi adapter classifies omp sessions past the title record and reads model", assert.equal(toolCall.name, "bash"); assert.equal(toolCall.result, "nothing to commit"); }); +test("pi adapter accepts only line one or line two after a title header", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-header-")); + const title = JSON.stringify({ type: "title", v: 1, title: "" }); + const session = JSON.stringify({ type: "session", version: 3, id: "late", cwd: "/repo/demo" }); + const other = JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }); + const afterTitle = path.join(dir, "after-title.jsonl"); + const afterOther = path.join(dir, "after-other.jsonl"); + + fs.writeFileSync(afterTitle, `${title}\n${other}\n${session}\n`); + fs.writeFileSync(afterOther, `${other}\n${session}\n`); + + assert.equal(pi.classify(candidateFor(afterTitle)), null, "line three is outside the header"); + assert.equal(pi.classify(candidateFor(afterOther)), null, "line two is a header only after a title record"); +}); + +test("pi adapter links an OMP subagent to its sibling parent session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + assert.equal(child.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); + assert.equal(pi.classify(candidateFor(parentPath)).parentSessionId, undefined); +}); +test("pi discovery checks a missing parent path once per scan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-parent-cache-")); + const sessionDir = path.join(root, "sessions", "-repo-demo"); + const firstPath = path.join(sessionDir, "first.jsonl"); + const secondPath = path.join(sessionDir, "second.jsonl"); + writePiSession(firstPath, { id: "first", cwd: "/repo/demo" }); + writePiSession(secondPath, { id: "second", cwd: "/repo/demo" }); + const candidates = [candidateFor(firstPath), candidateFor(secondPath)]; + const missingParentPath = path.join(root, "sessions", "-repo-demo.jsonl"); + const scanContext = pi.createScanContext(); + const originalStatSync = fs.statSync; + let parentProbes = 0; + + fs.statSync = function (file, ...args) { + if (file === missingParentPath) parentProbes += 1; + return originalStatSync.call(this, file, ...args); + }; + try { + for (const candidate of candidates) pi.classify(candidate, { scanContext }); + } finally { + fs.statSync = originalStatSync; + } + + assert.ok(parentProbes <= 1, "ordinary sessions in one store should not repeat the same missing-parent lookup"); +}); function writeOmpSession(file, { id, cwd }) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync( diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 96695143..6acdd90d 100644 --- a/test/analyze-reuse.test.js +++ b/test/analyze-reuse.test.js @@ -6,6 +6,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import { resolveRepo } from "../src/repo.js"; +import { loadConfig } from "../src/config.js"; +import { discoverForRun } from "../src/commands/scan.js"; import { State } from "../src/state.js"; import { resolveMemoryFiles } from "../src/memory.js"; import { foldForRun } from "../src/commands/propose.js"; @@ -308,3 +311,112 @@ test("old-hash leftover evidence cannot change the current fold's session count, }); }); }); + +test("OMP analysis persists parent observer identity and fold restores it for legacy evidence", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-fold-home-")); + const dir = initRepo(MEMORY); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + + const writeOmpTranscript = (file, id) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const entries = [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: dir }, + { type: "message", message: { role: "user", content: "Please build the project." } }, + { type: "message", message: { role: "assistant", content: "Ran make build as instructed." } }, + { type: "message", message: { role: "user", content: "Now run the tests too." } }, + { type: "message", message: { role: "assistant", content: "Tests pass." } }, + ]; + fs.writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + }; + + writeOmpTranscript(path.join(sessionRoot, `${parentName}.jsonl`), "parent-native"); + writeOmpTranscript(path.join(sessionRoot, parentName, "Subagent.jsonl"), "child-native"); + + const analyzed = runAnalyze(dir, home); + assert.equal(analyzed.status, 0, analyzed.output); + assert.equal(analyzed.summary.analyzed, 2, "the real analyzer writes evidence for parent and child"); + + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + const repo = resolveRepo(dir); + const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all" } }); + const state = new State(dir).ensure(); + config.state = state; + const ctx = { repo, config, scope: null, strict: false, limit: null }; + const { transcripts } = await discoverForRun(ctx); + assert.equal(transcripts.length, 2, "discovery returns both OMP transcripts"); + assert.equal(new Set(transcripts.map((transcript) => transcript.corroborationIdentity)).size, 1); + + const evidence = state.listEvidence(); + assert.equal(evidence.length, 2); + + const memoryFile = resolveMemoryFiles(dir, ["AGENTS.md", "CLAUDE.md"]).primary; + const memoryHash = evidence[0].memoryHash; + const foldCtx = { repo, config: { ...config, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, scope: null }; + + const current = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(current.gaps.length, 0, "parent and child count as one observer, below the two-session threshold"); + const currentSessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(currentSessionIds, [transcripts[0].corroborationIdentity]); + assert.ok( + evidence.every((record) => + transcripts.some( + (transcript) => + transcript.path === record.transcript.path && + transcript.corroborationIdentity === record.transcript.corroborationIdentity, + ), + ), + "analysis persists the observer identity needed by later folds", + ); + + const childEvidence = evidence.find((record) => record.transcript.path === childPath); + assert.ok(childEvidence); + for (const field of [ + "parentSessionId", + "corroborationIdentity", + "corroborationNativeId", + "corroborationStartedAt", + ]) { + delete childEvidence.transcript[field]; + } + state.writeEvidence(childEvidence.transcript, childEvidence); + + const reused = runAnalyze(dir, home); + assert.equal(reused.status, 0, reused.output); + assert.deepEqual([reused.summary.analyzed, reused.summary.cached], [0, 2]); + const reusedChild = state.listEvidence().find((record) => record.transcript.path === childPath); + assert.equal(reusedChild.transcript.parentSessionId, "parent-native"); + assert.equal(reusedChild.transcript.corroborationIdentity, transcripts[0].corroborationIdentity); + assert.equal(reusedChild.transcript.corroborationNativeId, "parent-native"); + + state.writeGapLedger({ version: 1, entries: {} }); + for (const record of evidence) { + const transcript = { ...record.transcript }; + delete transcript.parentSessionId; + delete transcript.corroborationIdentity; + delete transcript.corroborationNativeId; + delete transcript.corroborationStartedAt; + state.writeEvidence(transcript, { ...record, transcript }); + } + + const legacy = await foldForRun(foldCtx, memoryFile, memoryHash, [], transcripts); + assert.equal(legacy.gaps.length, 0, "selected discovery metadata restores identity for older evidence"); + const legacySessionIds = Object.values(state.readGapLedger().entries).flatMap((entry) => + Object.keys(entry.sessions), + ); + assert.deepEqual(legacySessionIds, [transcripts[0].corroborationIdentity]); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + } +}); diff --git a/test/fold.test.js b/test/fold.test.js index 39147622..57297010 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -532,6 +532,74 @@ test("harm-class negatives are counted per distinct session, and only explicit h assert.equal(rows.get("AG-001").negative, 4); assert.equal(rows.get("AG-002").harmSessions, 0, "non-compliance and unclassified never count as harm"); }); +test("OMP subagents share corroboration while relevance remains per file", () => { + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: "pi-file-parent", + corroborationIdentity: "pi-parent-session", + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: "pi-parent-session", + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const independent = { + id: "pi-independent", + nativeId: "independent-native", + identity: "pi-file-independent", + corroborationIdentity: "pi-independent-session", + corroborationNativeId: "independent-native", + corroborationStartedAt: startedAt + 2_000, + harness: "pi", + startedAt: startedAt + 2_000, + interaction: "interactive", + }; + const observed = (transcript) => + record(transcript.id, { + transcript, + negative: [ + { instruction: "AG-001", quote: `harm ${transcript.id}`, class: "harm" }, + { instruction: "AG-002", quote: `ignored ${transcript.id}`, class: "non-compliance" }, + ], + gaps: [{ proposedInstruction: "Read the deployment runbook first.", quote: `gap ${transcript.id}` }], + }); + + const parentAndChild = foldEvidence([observed(parent), observed(child)], { minGapEvidence: 2, memoryFile }); + const oneObserver = new Map(parentAndChild.instructions.map((row) => [row.instruction, row])); + assert.equal(parentAndChild.gaps.length, 0, "parent and subagent cannot clear the two-session floor"); + assert.equal(parentAndChild.totals.droppedGapSingletons, 1); + assert.equal(parentAndChild.sources.length, 1, "both files share one visible evidence source"); + assert.equal(oneObserver.get("AG-001").harmSessions, 1); + assert.equal(oneObserver.get("AG-002").nonComplianceSessions, 1); + assert.equal(oneObserver.get("AG-001").sessions, 2, "relevance still measures both analyzed files"); + assert.equal(oneObserver.get("AG-001").relevance, 1); + + const independentlyCorroborated = foldEvidence([observed(parent), observed(child), observed(independent)], { + minGapEvidence: 2, + memoryFile, + }); + const twoObservers = new Map(independentlyCorroborated.instructions.map((row) => [row.instruction, row])); + assert.equal(independentlyCorroborated.gaps.length, 1); + assert.equal(independentlyCorroborated.gaps[0].sessions, 2); + assert.equal(independentlyCorroborated.sources.length, 2); + assert.equal(twoObservers.get("AG-001").harmSessions, 2); + assert.equal(twoObservers.get("AG-002").nonComplianceSessions, 2); + assert.equal(twoObservers.get("AG-001").sessions, 3); +}); test("failed-trigger citations count per skill and reach the synthesis prompt with the cluster", () => { const covered = (id, phrasing) => diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 417ed8c7..d46c0461 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -10,6 +10,7 @@ import { foldForRun } from "../src/commands/propose.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; import { gapEntryId, + normalizeGapLedgerSessions, ledgerGapObservations, mergeGapEntries, pruneGapLedger, @@ -126,6 +127,123 @@ test("the same session is never double-counted across runs", async () => { assert.equal(entries.length, 1, "rephrasings of one gap share one ledger entry"); assert.deepEqual(Object.keys(entries[0].sessions), ["claude-s1"]); }); +test("OMP parent and subagent persist one ledger sighting and keep it in the child sample", async () => { + const h = harness(); + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parentPath = "/omp/sessions/-repo-demo/parent-session.jsonl"; + const parentIdentity = "pi-parent-session"; + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: "pi-file-parent", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: parentPath, + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session/Subagent.jsonl", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const independent = { + id: "claude-independent", + nativeId: "independent", + identity: "independent-session", + corroborationIdentity: "independent-session", + corroborationNativeId: "independent", + corroborationStartedAt: startedAt + 2_000, + harness: "claude", + path: "/claude/independent.jsonl", + startedAt: startedAt + 2_000, + interaction: "interactive", + }; + const evidence = (transcript) => { + const result = record(transcript.id, [GAP]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const parentEvidence = evidence(parent); + const childEvidence = evidence(child); + + const first = await run(h, [parentEvidence, childEvidence]); + assert.equal(first.gaps.length, 0, "a parent plus its subagent remains one observer"); + assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); + + const childOnly = await foldForRun(h.ctx, memoryFile(), "h1", [], [child]); + assert.equal(childOnly.totals.gapSightings, 1, "the sampled child keeps its parent's ledger sighting"); + assert.equal(childOnly.gaps.length, 0); + + const independentEvidence = evidence(independent); + h.state.writeEvidence(independent.id, independentEvidence); + const withIndependent = await foldForRun(h.ctx, memoryFile(), "h1", [], [child, independent]); + assert.equal(withIndependent.gaps.length, 1); + assert.equal(withIndependent.gaps[0].sessions, 2); +}); +test("a selected OMP child collapses legacy parent and child ledger sightings", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const parentIdentity = "pi-parent-session"; + const parent = { + id: "pi-parent", + nativeId: "parent-native", + identity: parentIdentity, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session.jsonl", + startedAt, + interaction: "interactive", + }; + const child = { + id: "pi-child", + nativeId: "child-native", + identity: "pi-file-child", + parentSessionId: "parent-native", + corroborationIdentity: parentIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + path: "/omp/sessions/-repo-demo/parent-session/Subagent.jsonl", + startedAt: startedAt + 1_000, + interaction: "non-interactive", + }; + const asEvidence = (transcript) => { + const result = record(transcript.id, [GAP]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const legacyChild = { ...child }; + delete legacyChild.parentSessionId; + delete legacyChild.corroborationIdentity; + delete legacyChild.corroborationNativeId; + delete legacyChild.corroborationStartedAt; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [asEvidence(parent), asEvidence(legacyChild)], { + now: new Date(startedAt + 2_000), + }); + h.state.writeGapLedger(ledger); + + const currentChildEvidence = asEvidence(child); + currentChildEvidence.gaps = []; + h.state.writeEvidence(child.id, currentChildEvidence); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [child]); + + assert.equal(summary.gaps.length, 0, "old parent and child keys still represent one observer"); + assert.equal(summary.totals.droppedGapSingletons, 1); + assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); +}); test("a legacy session-id observation migrates without counting the identity as a second session", async () => { const h = harness(); @@ -145,6 +263,56 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); +test("OMP ledger migration preserves selected order in one ledger pass", () => { + const ledger = { + version: 1, + entries: { + [gapEntryId(MEMORY_PATH, GAP)]: { + id: gapEntryId(MEMORY_PATH, GAP), + memoryPath: MEMORY_PATH, + proposedInstruction: GAP, + sessions: { + "parent-file": { firstObservedAt: "2026-08-01T00:00:00.000Z", project: "parent-project" }, + "child-file": { firstObservedAt: "2026-08-02T00:00:00.000Z", project: "child-project" }, + }, + }, + }, + }; + let ledgerPasses = 0; + const entries = ledger.entries; + ledger.entries = new Proxy(entries, { + ownKeys(target) { + ledgerPasses += 1; + return Reflect.ownKeys(target); + }, + }); + const parent = { + id: "pi-parent", + identity: "parent-file", + corroborationIdentity: "pi-parent", + corroborationNativeId: "parent-native", + harness: "pi", + path: "/omp/sessions/parent.jsonl", + startedAt: Date.parse("2026-08-01T00:00:00.000Z"), + }; + const child = { + id: "pi-child", + identity: "child-file", + corroborationIdentity: "pi-parent", + corroborationNativeId: "parent-native", + harness: "pi", + path: "/omp/sessions/parent/Subagent.jsonl", + startedAt: Date.parse("2026-08-02T00:00:00.000Z"), + }; + + normalizeGapLedgerSessions(ledger, [parent, child]); + assert.equal(ledgerPasses, 1, "the selected transcripts should share one ledger traversal"); + + const [entry] = Object.values(ledger.entries); + assert.deepEqual(Object.keys(entry.sessions), ["pi-parent"]); + assert.equal(entry.sessions["pi-parent"].project, "parent-project"); +}); + test("a genuine one-off never graduates, however many runs see it", async () => { const h = harness(); for (let i = 0; i < 5; i += 1) { diff --git a/test/interaction.test.js b/test/interaction.test.js index 2ede9b16..84d45355 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -749,6 +749,109 @@ test("evidence records carry the category and fold reports relevance per categor process.env.HOME = prevHome; } }); +test("OMP subagents share their parent identity and refresh cached relations", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(parentPath, header("parent-native")); + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + config.state = new State(repo).ensure(); + const cache = config.state.readScanCache(); + for (const candidate of pi.enumerate()) { + const descriptor = pi.classify(candidate); + delete descriptor.parentSessionId; + delete descriptor.parentSessionPath; + delete descriptor.parentSessionStartedAt; + cache.entries[`pi:${candidate.key}`] = { + mtimeMs: candidate.mtimeMs, + bytes: candidate.bytes, + descriptor, + }; + } + config.state.writeScanCache(cache); + + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(first.perHarness.pi.cached, 0, "the pre-relation cache must be reclassified"); + const parent = first.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity, "the two files remain separately analyzable"); + assert.equal(parent.corroborationIdentity, parent.identity); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(parent.interaction, INTERACTIVE); + assert.equal(child.interaction, NON_INTERACTIVE); + + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 2, "the refreshed relation is safe to reuse"); + assert.equal( + second.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + +test("Pi child cache is invalidated when its parent session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-parent-cache-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionRoot, `${parentName}.jsonl`); + const childPath = path.join(sessionRoot, parentName, "Subagent.jsonl"); + const header = (id) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd: repo }, + ]; + writeJsonl(childPath, header("child-native")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + + const first = await discoverTranscripts({ repo: repository, config }); + const childBeforeParent = first.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(childBeforeParent); + assert.equal(childBeforeParent.parentSessionId, undefined); + + writeJsonl(parentPath, header("parent-native")); + const second = await discoverTranscripts({ repo: repository, config }); + const parent = second.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = second.transcripts.find((transcript) => transcript.nativeId === "child-native"); + + assert.ok(parent && child); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(second.perHarness.pi.cached, 0, "the child descriptor must be reclassified after its parent appears"); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 2, "the refreshed parent relation is safe to reuse"); + assert.equal( + third.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, + parent.identity, + ); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); test("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index 9658a6d6..fd509e0e 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -40,6 +40,56 @@ function scenario({ variant = {}, cwdOverride = null, sessionText = null } = {}) }; } +test("remote OMP subagents keep their parent's corroboration identity", async () => { + const s = scenario(); + const sessionDir = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const writeSession = (file, id, timestamp) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${JSON.stringify({ type: "title", v: 1, title: "" })}\n` + + `${JSON.stringify({ + type: "session", + version: 3, + id, + timestamp, + cwd: s.remoteClone, + })}\n`, + ); + }; + writeSession(parentPath, "parent-native", "2026-08-27T00:00:00.000Z"); + writeSession(childPath, "child-native", "2026-08-27T00:01:00.000Z"); + + const piEnv = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "BB_DATA_DIR", "BB_PI_BRIDGE_SESSION_DIR"]; + const previous = Object.fromEntries(piEnv.map((key) => [key, process.env[key]])); + for (const key of piEnv) delete process.env[key]; + let result; + try { + result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all" }, + }), + ); + } finally { + for (const key of piEnv) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + + const parent = result.transcripts.find((transcript) => transcript.nativeId === "parent-native"); + const child = result.transcripts.find((transcript) => transcript.nativeId === "child-native"); + assert.ok(parent && child); + assert.notEqual(parent.identity, child.identity); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.corroborationIdentity, parent.identity); + assert.equal(child.corroborationNativeId, "parent-native"); + assert.equal(child.corroborationStartedAt, parent.startedAt); +}); + test("host collection has plain progress without duplicating live progress", async () => { const plain = scenario(); const lines = []; From 6ae6d9ae23876c55c09c1dbe0ea7101eb1f66bf9 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 12:54:10 -0500 Subject: [PATCH 03/17] no-mistakes(review): Guard ledger id migration, narrow fold overlay, nest OMP subagents --- README.md | 12 ++-- src/commands/propose.js | 28 +++++--- src/discovery/adapters/pi.js | 53 ++++++++++----- src/gap-ledger.js | 39 ++++------- test/adapters.test.js | 32 +++++++++ test/gap-ledger.test.js | 69 +++++++------------- test/interaction.test.js | 123 +++++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index e839a9a1..f7e93c5e 100644 --- a/README.md +++ b/README.md @@ -232,15 +232,17 @@ relocated config dir does not hide its sessions. The variable is read from backp environment: if you reach that profile through an alias that only prefixes `claude`, set it for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it). -Pi collection covers standalone sessions under `~/.pi/agent/sessions/` and BB-managed Pi -sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR`, +Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under +`~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It +also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots overlap, backpass scans every applicable layout and reads each JSONL file once. -OMP nests subagent JSONL files below each parent session. Backpass analyzes each file -separately, but uses the parent session as their shared corroboration source; a parent and -its subagents cannot count as independent sessions. +OMP nests subagent JSONL files below each parent session, and a subagent's own subagents +one level further down. Backpass analyzes each file separately, but uses the root session as +their shared corroboration source; a session and its subagents cannot count as independent +sessions. OpenCode collection reads both store layouts: OpenCode 1.x (`session`, `message`, `part`) and OpenCode 2.x (`session_v2`, `session_message`). For 2.x, session activity uses the later of the session's update time and its newest message's update time. diff --git a/src/commands/propose.js b/src/commands/propose.js index fb0ba771..f5472b35 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -28,12 +28,14 @@ import { pruneHostCache } from "../discovery/cache.js"; * the evidence files that fed an expired sighting are still on disk and would re-add it), * then cluster from the ledger. * - * Evidence is filtered to selected transcript identities, the current memory hash, and - * the analysis-index cache key. Current discovery metadata is overlaid on persisted - * records for folding, restoring observer and interaction details for legacy evidence - * without admitting records outside this run's sample. Folding leftover records would - * inflate `analyzedSessions` beyond the sampled corpus or score positional instruction - * aliases against an index they never saw. + * Evidence is filtered to selected transcript identities, the current memory hash and + * analysis-index cache key, and a valid interaction category. Reanalysis rewrites a + * transcript's evidence when an input changes, but records outside this run's window or + * cap remain on disk. Folding those records would inflate `analyzedSessions` beyond the + * sampled corpus or score positional instruction aliases against an index they never saw. + * Legacy records stay excluded until ordinary discovery and analysis backfill them. + * Current discovery's corroboration fields are overlaid on admitted records, so a + * subagent analyzed before its parent appeared still folds under the parent's identity. */ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], transcripts = [], { route = null } = {}) { const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config; @@ -47,11 +49,13 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans identitiesByLegacyId.get(legacyId).add(transcriptIdentity(record.transcript)); } const selectedGapSessions = new Set(selectedByIdentity.keys()); + const legacyIds = new Set(); for (const transcript of transcripts) { selectedGapSessions.add(corroborationIdentityOf(transcript)); const identities = identitiesByLegacyId.get(transcript.id); if (identities?.size === 1 && identities.has(transcriptIdentity(transcript))) { selectedGapSessions.add(transcript.id); + legacyIds.add(transcript.id); } } const relevant = []; @@ -60,18 +64,24 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans if ( record.memoryPath !== memoryFile.path || record.memoryHash !== memoryHash || + (record.transcript?.interaction !== INTERACTIVE && record.transcript?.interaction !== NON_INTERACTIVE) || !currentTranscript || !isEvidenceFresh(record, currentTranscript, memoryHash) ) { continue; } - const transcript = { ...record.transcript, ...currentTranscript }; - if (transcript.interaction !== INTERACTIVE && transcript.interaction !== NON_INTERACTIVE) continue; + const transcript = { + ...record.transcript, + parentSessionId: currentTranscript.parentSessionId || null, + corroborationIdentity: corroborationIdentityOf(currentTranscript), + corroborationNativeId: currentTranscript.corroborationNativeId || null, + corroborationStartedAt: currentTranscript.corroborationStartedAt ?? null, + }; relevant.push({ ...record, transcript }); } const ledger = state.readGapLedger(); - normalizeGapLedgerSessions(ledger, transcripts); + normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); recordGapObservations(ledger, relevant, { skills }); // Consolidate after recording, so the pass sees this run's sightings too: two // sessions coining the same brand-new gap in one parallel fan-out can only line up diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index a8b4c2df..2f5f94c9 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -20,8 +20,10 @@ import { * same JSONL shape under `~/.omp/agent/sessions/` and honors `PI_CODING_AGENT_DIR`, but * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` * entry is line 2 there. omp also writes subagent transcripts one level deeper, at - * `//.jsonl`. BB's Pi bridge writes the same JSONL shape - * directly under `/pi-bridge-sessions/`. + * `//.jsonl`, and their own subagents at + * `///..jsonl`; every descendant is related to + * the root session. BB's Pi bridge writes the same JSONL shape directly under + * `/pi-bridge-sessions/`. * * Entries form a parent/child tree but arrive in * order, so a linear read is faithful. `model_change` / `thinking_level_change` records @@ -32,6 +34,8 @@ import { export const name = "pi"; export const cacheVersion = 3; +const SUBAGENT_DEPTH = 2; + export function storeRoot() { return home(".pi", "agent", "sessions"); } @@ -91,13 +95,7 @@ export function enumerate() { for (const spec of storeSpecs()) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), - ...(spec.nested - ? listDirs(spec.path).flatMap((dir) => [ - ...listFiles(dir, ".jsonl"), - // omp nests subagent transcripts one level below the session files. - ...listDirs(dir).flatMap((sub) => listFiles(sub, ".jsonl")), - ]) - : []), + ...(spec.nested ? listDirs(spec.path).flatMap((dir) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), ]; for (const file of files) { const key = realpathOrResolve(file); @@ -111,6 +109,13 @@ export function enumerate() { return out; } +function sessionFiles(dir, depth) { + return [ + ...listFiles(dir, ".jsonl"), + ...(depth > 0 ? listDirs(dir).flatMap((sub) => sessionFiles(sub, depth - 1)) : []), + ]; +} + export function createScanContext() { return { parentHeaders: new Map() }; } @@ -152,9 +157,23 @@ function parentSessionPathFor(candidatePath) { return path.join(path.dirname(sessionDir), `${path.basename(sessionDir)}.jsonl`); } +function ancestorSessionPaths(candidatePath) { + const out = []; + let current = candidatePath; + for (let depth = 0; depth < SUBAGENT_DEPTH; depth += 1) { + current = parentSessionPathFor(current); + out.push(current); + } + return out; +} + /** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ export function cacheDependency(candidate, options = {}) { - return readParentSession(parentSessionPathFor(candidate.path), options.scanContext)?.fingerprint ?? null; + return JSON.stringify( + ancestorSessionPaths(candidate.path).map( + (ancestorPath) => readParentSession(ancestorPath, options.scanContext)?.fingerprint ?? null, + ), + ); } /** @param {{ scanContext?: { parentHeaders: Map } }} [options] */ @@ -173,13 +192,13 @@ export function classify(candidate, options = {}) { interactionSignals: emptyInteractionSignals(), }; - const parentPath = parentSessionPathFor(candidate.path); - const parentInfo = readParentSession(parentPath, scanContext); - const parent = parentInfo?.entry; - if (parent?.type === "session" && parent.cwd === entry.cwd) { - descriptor.parentSessionId = parent.id || path.basename(parentPath, ".jsonl"); - descriptor.parentSessionPath = parentPath; - descriptor.parentSessionStartedAt = parent.timestamp ? Date.parse(parent.timestamp) : parentInfo.stat.mtimeMs; + for (const ancestorPath of ancestorSessionPaths(candidate.path)) { + const ancestorInfo = readParentSession(ancestorPath, scanContext); + const ancestor = ancestorInfo?.entry; + if (ancestor?.cwd !== entry.cwd) continue; + descriptor.parentSessionId = ancestor.id || path.basename(ancestorPath, ".jsonl"); + descriptor.parentSessionPath = ancestorPath; + descriptor.parentSessionStartedAt = ancestor.timestamp ? Date.parse(ancestor.timestamp) : ancestorInfo.stat.mtimeMs; } return descriptor; diff --git a/src/gap-ledger.js b/src/gap-ledger.js index e6171746..eacd1a93 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -261,37 +261,25 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } return recorded; } -/** Re-key selected sessions in old ledgers when related transcript files now share an identity. */ -export function normalizeGapLedgerSessions(ledger, transcripts) { +/** + * Re-key selected sessions in old ledgers when related transcript files now share an + * identity. A legacy `transcript.id` key migrates only when it is in `legacyIds`: the ids + * the fold proved belong to exactly one evidence identity, so an ambiguous id never moves + * another session's sighting onto a selected one. + */ +export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = new Set() } = {}) { const selections = []; - const selectionsByAlias = new Map(); - for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); - const aliases = sessionIdentityAliases(transcript, sessionIdentity); - if (!aliases.length) continue; - - const index = selections.length; - selections.push({ transcript, sessionIdentity, aliases }); - for (const alias of aliases) { - let indexes = selectionsByAlias.get(alias); - if (!indexes) selectionsByAlias.set(alias, (indexes = [])); - indexes.push(index); - } + const aliases = sessionIdentityAliases(transcript, sessionIdentity).filter( + (alias) => alias !== transcript.id || legacyIds.has(alias), + ); + if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); } - if (!selections.length) return; for (const entry of Object.values(ledger.entries)) { - const pending = new Set(); - for (const identity of Object.keys(entry.sessions)) { - const indexes = selectionsByAlias.get(identity); - if (indexes) for (const index of indexes) pending.add(index); - } - - for (let index = 0; index < selections.length; index += 1) { - if (!pending.has(index)) continue; - const { transcript, sessionIdentity, aliases } = selections[index]; + for (const { transcript, sessionIdentity, aliases } of selections) { if (!aliases.some((identity) => entry.sessions[identity])) continue; const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, @@ -324,9 +312,6 @@ export function normalizeGapLedgerSessions(ledger, transcripts) { ...(projectRoot ? { projectRoot } : {}), }; for (const alias of aliases) delete entry.sessions[alias]; - - const next = selectionsByAlias.get(sessionIdentity); - if (next) for (const nextIndex of next) if (nextIndex > index) pending.add(nextIndex); } } } diff --git a/test/adapters.test.js b/test/adapters.test.js index 1930aa3d..877decf2 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -194,6 +194,24 @@ test("pi adapter links an OMP subagent to its sibling parent session", () => { assert.equal(pi.classify(candidateFor(parentPath)).parentSessionId, undefined); }); +test("pi adapter links a second-level OMP subagent to the root session", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/repo/demo" }); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.id, "grandchild-native"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); + assert.equal(grandchild.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); +}); + test("pi discovery checks a missing parent path once per scan", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-parent-cache-")); const sessionDir = path.join(root, "sessions", "-repo-demo"); @@ -285,6 +303,19 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl id: "omp-subagent", cwd: "/repo/demo", }); + writeOmpSession( + path.join( + fakeHome, + ".omp", + "agent", + "sessions", + "-repo-demo", + "omp-standalone", + "Subagent", + "Subagent.Child.jsonl", + ), + { id: "omp-nested-subagent", cwd: "/repo/demo" }, + ); writePiSession(path.join(piAgentDir, "sessions", "-repo-demo", "custom-agent.jsonl"), { id: "custom-agent", cwd: "/repo/demo", @@ -320,6 +351,7 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl "direct-override.jsonl", "omp-standalone.jsonl", "standalone.jsonl", + "Subagent.Child.jsonl", "Subagent.jsonl", ].sort(), ); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index d46c0461..fc8e33dc 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -10,7 +10,6 @@ import { foldForRun } from "../src/commands/propose.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; import { gapEntryId, - normalizeGapLedgerSessions, ledgerGapObservations, mergeGapEntries, pruneGapLedger, @@ -263,54 +262,34 @@ test("a legacy session-id observation migrates without counting the identity as assert.equal(entry.sessions["stable-identity-s1"].firstObservedAt, firstObservedAt); }); -test("OMP ledger migration preserves selected order in one ledger pass", () => { - const ledger = { - version: 1, - entries: { - [gapEntryId(MEMORY_PATH, GAP)]: { - id: gapEntryId(MEMORY_PATH, GAP), - memoryPath: MEMORY_PATH, - proposedInstruction: GAP, - sessions: { - "parent-file": { firstObservedAt: "2026-08-01T00:00:00.000Z", project: "parent-project" }, - "child-file": { firstObservedAt: "2026-08-02T00:00:00.000Z", project: "child-project" }, - }, - }, - }, +test("an ambiguous legacy session id never migrates onto a selected session", async () => { + const h = harness({ gapLedgerMaxAge: "all" }); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; }; - let ledgerPasses = 0; - const entries = ledger.entries; - ledger.entries = new Proxy(entries, { - ownKeys(target) { - ledgerPasses += 1; - return Reflect.ownKeys(target); - }, + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, }); - const parent = { - id: "pi-parent", - identity: "parent-file", - corroborationIdentity: "pi-parent", - corroborationNativeId: "parent-native", - harness: "pi", - path: "/omp/sessions/parent.jsonl", - startedAt: Date.parse("2026-08-01T00:00:00.000Z"), - }; - const child = { - id: "pi-child", - identity: "child-file", - corroborationIdentity: "pi-parent", - corroborationNativeId: "parent-native", - harness: "pi", - path: "/omp/sessions/parent/Subagent.jsonl", - startedAt: Date.parse("2026-08-02T00:00:00.000Z"), - }; + const a = shared("session-a"); + const b = shared("session-b"); + const c = record("claude-c", []).transcript; + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [record("claude-shared", [GAP]), record("claude-c", [GAP])]); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); - normalizeGapLedgerSessions(ledger, [parent, child]); - assert.equal(ledgerPasses, 1, "the selected transcripts should share one ledger traversal"); + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a, c]); - const [entry] = Object.values(ledger.entries); - assert.deepEqual(Object.keys(entry.sessions), ["pi-parent"]); - assert.equal(entry.sessions["pi-parent"].project, "parent-project"); + assert.equal(summary.gaps.length, 0, "a sighting that may be B's must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-c", "claude-shared"]); }); test("a genuine one-off never graduates, however many runs see it", async () => { diff --git a/test/interaction.test.js b/test/interaction.test.js index 84d45355..8cc64f21 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -25,6 +25,7 @@ import { foldForRun, printProposal } from "../src/commands/propose.js"; import { cmdScan } from "../src/commands/scan.js"; import { renderApplySurface } from "../src/apply/lavish.js"; import { evidenceKey, State } from "../src/state.js"; +import { recordGapObservations } from "../src/gap-ledger.js"; import { transcriptIdentity } from "../src/transcript.js"; import { sampleTranscripts, capTranscripts } from "../src/sample.js"; import { setLoggerSink } from "../src/logger.js"; @@ -478,6 +479,44 @@ test("fold excludes legacy evidence without an interaction category", async () = assert.equal(state.readEvidence(transcript).transcript.interaction, undefined); }); +test("fold keeps legacy evidence excluded when current discovery stamps an interaction", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-fold-current-")); + const state = new State(dir).ensure(); + const transcript = { + harness: "codex", + id: "codex-legacy-current", + nativeId: "legacy-current", + path: "/sessions/legacy-current.jsonl", + mtimeMs: 100, + bytes: 200, + interaction: INTERACTIVE, + }; + const memoryHash = "sha256:memory"; + state.writeEvidence(transcript, { + status: "ok", + transcript: { harness: "codex", id: transcript.id, path: transcript.path }, + memoryHash, + memoryPath: "AGENTS.md", + key: evidenceKey(transcript, memoryHash), + positive: [{ instruction: "AG-001", quote: "followed the repository rule exactly" }], + negative: [], + gaps: [], + }); + + const summary = await foldForRun( + { + repo: { root: dir }, + config: { state, minGapEvidence: 2, gapLedgerMaxAge: "90d" }, + }, + { path: "AGENTS.md", text: "", units: [] }, + memoryHash, + [], + [transcript], + ); + + assert.equal(summary.analyzedSessions, 0, "only analysis may backfill a stored interaction stamp"); +}); + test("fold selection distinguishes colliding native IDs by source", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-mix-identity-fold-")); const state = new State(dir).ensure(); @@ -853,6 +892,90 @@ test("Pi child cache is invalidated when its parent session appears", async () = } }); +test("nested OMP subagents share the root identity once the root session appears", async () => { + const repo = initRepo(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-nested-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, timestamp) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp, cwd: repo }, + ]; + writeJsonl(childPath, header("child-native", "2026-08-27T00:01:00.000Z")); + writeJsonl(grandchildPath, header("grandchild-native", "2026-08-27T00:02:00.000Z")); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + config.state = new State(repo).ensure(); + config.gapLedgerMaxAge = "all"; + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const byNativeId = (result, nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + + const first = await discoverTranscripts({ repo: repository, config }); + assert.equal(byNativeId(first, "child-native").parentSessionId, undefined); + assert.equal(byNativeId(first, "grandchild-native").parentSessionId, "child-native"); + + writeJsonl(rootPath, header("root-native", "2026-08-27T00:00:00.000Z")); + const second = await discoverTranscripts({ repo: repository, config }); + assert.equal(second.perHarness.pi.cached, 0, "both descendants must be reclassified when the root appears"); + const root = byNativeId(second, "root-native"); + const child = byNativeId(second, "child-native"); + const grandchild = byNativeId(second, "grandchild-native"); + assert.ok(root && child && grandchild); + assert.equal(new Set([root.identity, child.identity, grandchild.identity]).size, 3, "each file stays analyzable"); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.corroborationStartedAt, root.startedAt); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.corroborationIdentity, root.identity); + assert.equal(root.interaction, INTERACTIVE); + + const third = await discoverTranscripts({ repo: repository, config }); + assert.equal(third.perHarness.pi.cached, 3, "the refreshed relations are safe to reuse"); + assert.equal(byNativeId(third, "grandchild-native").corroborationIdentity, root.identity); + + const perFile = (transcript) => ({ + status: "ok", + memoryPath: "AGENTS.md", + memoryHash: "sha256:memory", + transcript: { ...transcript, parentSessionId: null, corroborationIdentity: null, corroborationNativeId: null }, + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived it", + quote: "quote", + recurrenceRisk: "high", + }, + ], + }); + const ledger = { version: 1, entries: {} }; + recordGapObservations(ledger, [perFile(child), perFile(grandchild)]); + config.state.writeGapLedger(ledger); + + const summary = await foldForRun( + { repo: { root: repo }, config }, + { path: "AGENTS.md", text: "", units: [] }, + "sha256:memory", + [], + [child, grandchild], + ); + assert.equal(summary.gaps.length, 0, "per-file sightings of one root session are one observer"); + const [entry] = Object.values(config.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions), [root.identity]); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + test("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ harness: "claude", From 6a63d70c0d6bde0f08126bab4ea8b6ca3907c95e Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 12:59:50 -0500 Subject: [PATCH 04/17] no-mistakes(review): Drop graft cache files, guard ledger recording aliases --- src/commands/propose.js | 2 +- src/gap-ledger.js | 15 +++++++-------- test/gap-ledger.test.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/commands/propose.js b/src/commands/propose.js index f5472b35..04522c3e 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -82,7 +82,7 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans const ledger = state.readGapLedger(); normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); - recordGapObservations(ledger, relevant, { skills }); + recordGapObservations(ledger, relevant, { skills, legacyIds }); // Consolidate after recording, so the pass sees this run's sightings too: two // sessions coining the same brand-new gap in one parallel fan-out can only line up // here. One bounded judged call; a failure degrades to lexical identity and the run diff --git a/src/gap-ledger.js b/src/gap-ledger.js index eacd1a93..b743ee58 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -165,20 +165,21 @@ export function findGapEntry(ledger, memoryPath, proposedInstruction) { return best; } -function sessionIdentityAliases(transcript, sessionIdentity) { +function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { return [...new Set([transcript.identity, transcript.id])].filter( - (identity) => identity && identity !== sessionIdentity, + (identity) => identity && identity !== sessionIdentity && (identity !== transcript.id || legacyIds.has(identity)), ); } /** * Fold this run's evidence into the ledger. One observation per (gap, session); a * session seen again replaces its own observation and keeps its first-seen timestamp. + * A legacy `transcript.id` key is that session's only when it is in `legacyIds`. * - * @param {{ now?: Date, skills?: unknown[] }} [options] + * @param {{ now?: Date, skills?: unknown[], legacyIds?: Set }} [options] */ export function recordGapObservations(ledger, evidenceRecords, options = {}) { - const { now = new Date() } = options; + const { now = new Date(), legacyIds = new Set() } = options; const observedAt = new Date(now).toISOString(); let recorded = 0; for (const record of evidenceRecords) { @@ -215,7 +216,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const aliases = sessionIdentityAliases(transcript, sessionIdentity); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, ); @@ -272,9 +273,7 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); - const aliases = sessionIdentityAliases(transcript, sessionIdentity).filter( - (alias) => alias !== transcript.id || legacyIds.has(alias), - ); + const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); } diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index fc8e33dc..9a9f13ef 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -292,6 +292,41 @@ test("an ambiguous legacy session id never migrates onto a selected session", as assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-c", "claude-shared"]); }); +test("a selected session recording a gap never inherits an ambiguous legacy id's sighting", async () => { + const h = harness(); + const shared = (identity) => ({ + ...record("claude-shared", []).transcript, + nativeId: "shared", + identity, + path: `/claude/${identity}.jsonl`, + }); + const a = shared("session-a"); + const b = shared("session-b"); + const evidenceFor = (transcript, gaps) => { + const result = record(transcript.id, gaps); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const ledger = { version: 1, entries: {} }; + const bFirstObservedAt = new Date(Date.now() - 80 * DAY); + recordGapObservations(ledger, [record("claude-shared", [GAP])], { now: bFirstObservedAt }); + h.state.writeGapLedger(ledger); + h.state.writeEvidence(a, evidenceFor(a, [GAP])); + h.state.writeEvidence(b, evidenceFor(b, [GAP])); + + const summary = await foldForRun(h.ctx, memoryFile(), "h1", [], [a]); + + assert.equal(summary.gaps.length, 0, "B's sighting must not corroborate A"); + const [entry] = Object.values(h.state.readGapLedger().entries); + assert.deepEqual(Object.keys(entry.sessions).sort(), ["claude-shared", "session-a"]); + assert.equal(entry.sessions["claude-shared"].firstObservedAt, bFirstObservedAt.toISOString()); + assert.ok( + Date.parse(entry.sessions["session-a"].firstObservedAt) > bFirstObservedAt.getTime(), + "A's fresh sighting keeps its own first-seen time", + ); +}); + test("a genuine one-off never graduates, however many runs see it", async () => { const h = harness(); for (let i = 0; i < 5; i += 1) { From 18f7cf617f524af5fd2201ac37eb9e460beb92e8 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:43:13 -0500 Subject: [PATCH 05/17] no-mistakes(review): Make shared OMP domain vote order-independent --- src/gap-ledger.js | 6 +++++- test/gap-ledger.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/gap-ledger.js b/src/gap-ledger.js index b743ee58..ab8cf1d2 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -249,7 +249,11 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { quote: gap.quote, recurrenceRisk: gap.recurrenceRisk, phrasings, - domain: gap.domain === "orchestration" ? "orchestration" : "project", + domain: + gap.domain === "orchestration" && + !priors.some((observation) => observation.observedAt === observedAt && observation.domain !== "orchestration") + ? "orchestration" + : "project", // A failed trigger: the analysis judged an existing skill's content to cover // this mistake. Absent when no skill covers it (including all pre-existing // observations), and absence never counts as a citation. diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 9a9f13ef..844e9b30 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -244,6 +244,46 @@ test("a selected OMP child collapses legacy parent and child ledger sightings", assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); }); +test("an OMP parent and subagent sharing a root vote project in one run whatever the order", () => { + const startedAt = Date.parse("2026-08-01T00:00:00Z"); + const rootIdentity = "pi-parent-session"; + const observe = (id, domain) => { + const transcript = { + id, + nativeId: `${id}-native`, + identity: `pi-file-${id}`, + corroborationIdentity: rootIdentity, + corroborationNativeId: "parent-native", + corroborationStartedAt: startedAt, + harness: "pi", + startedAt, + interaction: "interactive", + }; + const result = record(id, [{ proposedInstruction: GAP, domain }]); + result.transcript = transcript; + result.key = evidenceKey(transcript, result.memoryHash); + return result; + }; + const parent = observe("pi-parent", "project"); + const child = observe("pi-child", "orchestration"); + const domainAfter = (records, ledger = { version: 1, entries: {} }, now = new Date(startedAt + DAY)) => { + recordGapObservations(ledger, records, { now }); + const [entry] = Object.values(ledger.entries); + assert.deepEqual(Object.keys(entry.sessions), [rootIdentity]); + return { ledger, domain: entry.sessions[rootIdentity].domain }; + }; + + assert.equal(domainAfter([parent, child]).domain, "project"); + assert.equal(domainAfter([child, parent]).domain, "project"); + + const { ledger } = domainAfter([parent]); + assert.equal( + domainAfter([child], ledger, new Date(startedAt + 2 * DAY)).domain, + "orchestration", + "a later run still replaces the earlier vote", + ); +}); + test("a legacy session-id observation migrates without counting the identity as a second session", async () => { const h = harness(); await run(h, [record("claude-s1", [GAP])]); From cfdb19bede8cec192e452339ff9c1425dec151bb Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:08:36 -0500 Subject: [PATCH 06/17] no-mistakes(review): Link OMP subagents by nested path, share ledger prior merge --- src/discovery/adapters/pi.js | 4 ++-- src/gap-ledger.js | 41 ++++++++++++++++---------------- test/adapters.test.js | 22 ++++++++++++++++++ test/interaction.test.js | 45 ++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 2f5f94c9..c60cdad8 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -32,7 +32,7 @@ import { */ export const name = "pi"; -export const cacheVersion = 3; +export const cacheVersion = 4; const SUBAGENT_DEPTH = 2; @@ -195,7 +195,7 @@ export function classify(candidate, options = {}) { for (const ancestorPath of ancestorSessionPaths(candidate.path)) { const ancestorInfo = readParentSession(ancestorPath, scanContext); const ancestor = ancestorInfo?.entry; - if (ancestor?.cwd !== entry.cwd) continue; + if (!ancestor) continue; descriptor.parentSessionId = ancestor.id || path.basename(ancestorPath, ".jsonl"); descriptor.parentSessionPath = ancestorPath; descriptor.parentSessionStartedAt = ancestor.timestamp ? Date.parse(ancestor.timestamp) : ancestorInfo.stat.mtimeMs; diff --git a/src/gap-ledger.js b/src/gap-ledger.js index ab8cf1d2..72e97557 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -171,6 +171,19 @@ function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { ); } +function takePriorObservations(entry, sessionIdentity, aliases) { + const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + Boolean, + ); + const firstObservedAt = priors + .map((observation) => observation.firstObservedAt || observation.observedAt) + .filter((value) => Number.isFinite(Date.parse(value))) + .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; + const coveredBySkill = priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + for (const alias of aliases) delete entry.sessions[alias]; + return { priors, firstObservedAt, coveredBySkill }; +} + /** * Fold this run's evidence into the ledger. One observation per (gap, session); a * session seen again replaces its own observation and keeps its first-seen timestamp. @@ -216,17 +229,13 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } - const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); - const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( - Boolean, + const prior = takePriorObservations( + entry, + sessionIdentity, + sessionIdentityAliases(transcript, sessionIdentity, legacyIds), ); - const firstObservedAt = priors - .map((observation) => observation.firstObservedAt || observation.observedAt) - .filter((value) => Number.isFinite(Date.parse(value))) - .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; - for (const alias of aliases) delete entry.sessions[alias]; - const coveredBySkill = - gap.coveredBySkill || priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + const { priors, firstObservedAt } = prior; + const coveredBySkill = gap.coveredBySkill || prior.coveredBySkill; const phrasings = [ ...new Set([ ...priors.flatMap( @@ -284,15 +293,8 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne for (const entry of Object.values(ledger.entries)) { for (const { transcript, sessionIdentity, aliases } of selections) { if (!aliases.some((identity) => entry.sessions[identity])) continue; - const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( - Boolean, - ); - const current = entry.sessions[sessionIdentity] || priors[0]; - const firstObservedAt = priors - .map((observation) => observation.firstObservedAt || observation.observedAt) - .filter((value) => Number.isFinite(Date.parse(value))) - .sort((a, b) => Date.parse(a) - Date.parse(b))[0]; - const coveredBySkill = priors.find((observation) => observation.coveredBySkill)?.coveredBySkill; + const { priors, firstObservedAt, coveredBySkill } = takePriorObservations(entry, sessionIdentity, aliases); + const current = priors[0]; const project = current.project || priors.find((observation) => observation.project)?.project; const projectRoot = current.projectRoot || priors.find((observation) => observation.projectRoot)?.projectRoot; @@ -314,7 +316,6 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne ...(project ? { project } : {}), ...(projectRoot ? { projectRoot } : {}), }; - for (const alias of aliases) delete entry.sessions[alias]; } } } diff --git a/test/adapters.test.js b/test/adapters.test.js index 877decf2..2b4fa11e 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -212,6 +212,28 @@ test("pi adapter links a second-level OMP subagent to the root session", () => { assert.equal(grandchild.parentSessionStartedAt, Date.parse("2026-08-27T00:00:00.000Z")); }); +test("pi adapter links OMP subagents by nested path even when their cwd differs", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-")); + const sessionDir = path.join(root, "-repo-demo"); + const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; + const parentPath = path.join(sessionDir, `${parentName}.jsonl`); + const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionDir, parentName, "Subagent", "Subagent.Child.jsonl"); + writeOmpSession(parentPath, { id: "parent-native", cwd: "/repo/demo" }); + writeOmpSession(childPath, { id: "child-native", cwd: "/repo/demo/packages/api" }); + writeOmpSession(grandchildPath, { id: "grandchild-native", cwd: "/worktrees/demo-isolated" }); + + const child = pi.classify(candidateFor(childPath)); + assert.equal(child.cwd, "/repo/demo/packages/api", "association still uses the subagent's own cwd"); + assert.equal(child.parentSessionId, "parent-native"); + assert.equal(child.parentSessionPath, parentPath); + + const grandchild = pi.classify(candidateFor(grandchildPath)); + assert.equal(grandchild.cwd, "/worktrees/demo-isolated"); + assert.equal(grandchild.parentSessionId, "parent-native"); + assert.equal(grandchild.parentSessionPath, parentPath); +}); + test("pi discovery checks a missing parent path once per scan", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-pi-parent-cache-")); const sessionDir = path.join(root, "sessions", "-repo-demo"); diff --git a/test/interaction.test.js b/test/interaction.test.js index 8cc64f21..226941c1 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -976,6 +976,51 @@ test("nested OMP subagents share the root identity once the root session appears } }); +test("OMP subagents running in another repo cwd still share the root identity", async () => { + const repo = initRepo(); + const subdir = path.join(repo, "packages", "api"); + fs.mkdirSync(subdir, { recursive: true }); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "backpass-omp-cwd-discovery-")); + const sessionRoot = path.join(home, ".omp", "agent", "sessions", "-repo-demo"); + const rootName = "2026-08-27T00-00-00.000Z_root-folder"; + const rootPath = path.join(sessionRoot, `${rootName}.jsonl`); + const childPath = path.join(sessionRoot, rootName, "Subagent.jsonl"); + const grandchildPath = path.join(sessionRoot, rootName, "Subagent", "Subagent.Child.jsonl"); + const header = (id, cwd) => [ + { type: "title", v: 1, title: "" }, + { type: "session", version: 3, id, timestamp: "2026-08-27T00:00:00.000Z", cwd }, + ]; + writeJsonl(rootPath, header("root-native", repo)); + writeJsonl(childPath, header("child-native", subdir)); + writeJsonl(grandchildPath, header("grandchild-native", subdir)); + + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + config.state = new State(repo).ensure(); + const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; + const result = await discoverTranscripts({ repo: repository, config }); + const byNativeId = (nativeId) => result.transcripts.find((transcript) => transcript.nativeId === nativeId); + const root = byNativeId("root-native"); + const child = byNativeId("child-native"); + const grandchild = byNativeId("grandchild-native"); + + assert.ok(root && child && grandchild, "a subagent in a repo subdirectory still maps to this repository"); + assert.equal(child.cwd, subdir); + for (const descendant of [child, grandchild]) { + assert.equal(descendant.parentSessionId, "root-native"); + assert.equal(descendant.corroborationIdentity, root.identity); + assert.equal(descendant.corroborationNativeId, "root-native"); + assert.equal(descendant.interaction, NON_INTERACTIVE); + } + assert.equal(root.interaction, INTERACTIVE); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + } +}); + test("the sampler keeps both categories when a 98% non-interactive corpus exceeds the cap", () => { const interactive = Array.from({ length: 2 }, (_, i) => ({ harness: "claude", From 1b8ba492a9582326a3eb7c20a4afa1951ee7f87a Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:44:25 -0500 Subject: [PATCH 07/17] no-mistakes(document): Sync OMP interaction and scan-cache doc comments --- src/discovery/index.js | 6 ++++-- src/interaction.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/discovery/index.js b/src/discovery/index.js index 6b467090..0a89d0f5 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -39,8 +39,10 @@ export function getAdapter(harness) { * Discovery (design section 2). * * For file-backed stores the expensive step is reading each transcript's header, so - * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. - * Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on + * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. An + * adapter may also export `cacheVersion` (bumped when its classification semantics + * change) and `cacheDependency` (a fingerprint of other files a descriptor reads, such + * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on * the machine this was designed against. * * SQLite-backed stores (opencode, hermes, cursor IDE) query session metadata directly, diff --git a/src/interaction.js b/src/interaction.js index bcdde1bb..807ebd44 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -6,8 +6,8 @@ * * Non-interactive is detected best-effort from per-harness metadata (codex * `originator: codex_exec` / `source: exec`, claude `entrypoint` values that start - * with `sdk`, an OpenCode child `parent_id`, Hermes cron/gateway/whatsapp if they - * ever leak past discovery) and from cwd (a `.no-mistakes` path segment - pipeline + * with `sdk`, an OpenCode child `parent_id`, an OMP subagent's `parentSessionId`, + * Hermes cron/gateway/whatsapp if they ever leak past discovery) and from cwd (a `.no-mistakes` path segment - pipeline * worktrees are one kind of non-interactive run, not their own category). */ From 4809bae86a37e03cd77cde43d003e587a3712261 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 15:51:54 -0500 Subject: [PATCH 08/17] chore: ignore local Graft cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 090be833..f22397f3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .DS_Store *.log package-lock.json +graft/.cache/ From 77185bf99da99f05b848f1d47d84b3b0696286a0 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 24 Sep 2026 16:15:09 -0500 Subject: [PATCH 09/17] no-mistakes(document): Sync OMP identity docs and rewrap long comments --- README.md | 9 ++++----- src/discovery/adapters/pi.js | 8 ++++---- src/discovery/index.js | 5 +++-- src/gap-ledger.js | 12 ++++++++---- src/interaction.js | 9 ++++++--- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f7e93c5e..a4bad21a 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ backpass reads the local transcript stores of seven harnesses directly. No API, | -------------- | ---------------------------------------------- | --------------------------------------------------- | | **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | | **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | -| **pi** | standalone and BB-managed Pi JSONL stores | session-header `cwd` | +| **pi** | standalone, OMP, and BB-managed Pi JSONL stores | session-header `cwd` | | **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | | **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | | **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | @@ -234,10 +234,9 @@ for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under `~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It -also honors `PI_CODING_AGENT_DIR`, -`PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are -set in backpass's environment. When roots overlap, backpass scans every applicable layout -and reads each JSONL file once. +also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and +`BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots +overlap, backpass scans every applicable layout and reads each JSONL file once. OMP nests subagent JSONL files below each parent session, and a subagent's own subagents one level further down. Backpass analyzes each file separately, but uses the root session as diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index c60cdad8..70b877a8 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -25,10 +25,10 @@ import { * the root session. BB's Pi bridge writes the same JSONL shape directly under * `/pi-bridge-sessions/`. * - * Entries form a parent/child tree but arrive in - * order, so a linear read is faithful. `model_change` / `thinking_level_change` records - * give the model actually used (`modelId` on pi, `model` on omp). No remote is - * recorded - dead worktrees reach tier 3 only. + * Entries form a parent/child tree but arrive in order, so a linear read is faithful. + * `model_change` / `thinking_level_change` records give the model actually used + * (`modelId` on pi, `model` on omp). No remote is recorded - dead worktrees reach tier 3 + * only. */ export const name = "pi"; diff --git a/src/discovery/index.js b/src/discovery/index.js index 0a89d0f5..e55c508a 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -42,8 +42,9 @@ export function getAdapter(harness) { * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. An * adapter may also export `cacheVersion` (bumped when its classification semantics * change) and `cacheDependency` (a fingerprint of other files a descriptor reads, such - * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Re-scans are then O(new files) - which matters: codex alone had 10,317 rollouts on - * the machine this was designed against. + * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Re-scans are + * then O(new files) - which matters: codex alone had 10,317 rollouts on the machine this + * was designed against. * * SQLite-backed stores (opencode, hermes, cursor IDE) query session metadata directly, * so they skip the file-header cache entirely. diff --git a/src/gap-ledger.js b/src/gap-ledger.js index 72e97557..d38ca9f1 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -35,10 +35,14 @@ import { corroborationIdentityOf } from "./transcript.js"; * afterward (majority orchestration withholds a cluster from proposals; a mixed * cluster stays visible). A missing domain counts as project, so evidence from * before the field existed keeps its old behavior. - * - Sessions are keyed by canonical transcript identity (with the legacy id as a fallback), - * so re-analyzing or re-sampling the same source session overwrites its observation and - * never adds a count. Persisted observations only contribute when that identity belongs - * to the current selected sample, so sessions outside the window or cap cannot skew fold. + * - Sessions are keyed by corroboration identity (`corroborationIdentityOf`: the root + * session's canonical identity for an OMP subagent, the transcript's own otherwise), so + * re-analyzing or re-sampling the same source session, or a subagent of it, overwrites + * its observation and never adds a count. An older per-file identity key migrates to + * it; a legacy id key migrates only when the fold proved that id belongs to one + * evidence identity (`legacyIds`). Persisted observations only contribute when that + * identity belongs to the current selected sample, so sessions outside the window or + * cap cannot skew fold. * - A gap is a fact about its session: re-analysis that no longer mentions it is model * noise, not the session changing, so observations are only ever replaced, not removed * by absence. They retire in exactly two ways: the memory surface gains content diff --git a/src/interaction.js b/src/interaction.js index 807ebd44..3fd28e9d 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -7,8 +7,9 @@ * Non-interactive is detected best-effort from per-harness metadata (codex * `originator: codex_exec` / `source: exec`, claude `entrypoint` values that start * with `sdk`, an OpenCode child `parent_id`, an OMP subagent's `parentSessionId`, - * Hermes cron/gateway/whatsapp if they ever leak past discovery) and from cwd (a `.no-mistakes` path segment - pipeline - * worktrees are one kind of non-interactive run, not their own category). + * Hermes cron/gateway/whatsapp if they ever leak past discovery) and from cwd (a + * `.no-mistakes` path segment - pipeline worktrees are one kind of non-interactive run, + * not their own category). */ export const INTERACTIVE = "interactive"; @@ -57,7 +58,9 @@ function claudeEntrypointIsNonInteractive(entrypoint) { /** * Map a discovered transcript (or adapter descriptor) onto the two public categories. - * Explicit `transcript.interaction` is trusted when it is already one of the two labels. + * A `parentSessionId` (an OMP subagent) is always non-interactive, even over a stamped + * label; otherwise an explicit `transcript.interaction` is trusted when it is already one + * of the two labels. */ export function classifyInteraction(transcript) { if (transcript?.parentSessionId) return NON_INTERACTIVE; From 8afb78628143fb4db1b44ef72364cfe40cff4e85 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Thu, 1 Oct 2026 17:25:03 -0500 Subject: [PATCH 10/17] feat(discovery): make OMP session collection opt-in --- README.md | 41 ++++++++++++++++++++++++----------- src/config.js | 5 +++++ src/discovery/adapters/pi.js | 19 ++++++++++------ src/discovery/hosts.js | 10 ++++----- src/discovery/index.js | 1 + src/discovery/remote/probe.js | 10 ++++----- test/adapters.test.js | 19 +++++++++++++++- test/analyze-reuse.test.js | 6 ++++- test/config.test.js | 9 ++++++++ test/interaction.test.js | 13 ++++++----- test/remote-discovery.test.js | 8 ++++++- 11 files changed, 103 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a4bad21a..e6ca82e9 100644 --- a/README.md +++ b/README.md @@ -217,26 +217,40 @@ It cannot be combined with backpass reads the local transcript stores of seven harnesses directly. No API, no upload. -| Harness | Store | Repo tie | -| -------------- | ---------------------------------------------- | --------------------------------------------------- | -| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | -| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | +| Harness | Store | Repo tie | +| -------------- | ----------------------------------------------- | --------------------------------------------------- | +| **claude** | `~/.claude/projects//.jsonl` | per-line `cwd` | +| **codex** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | `cwd` + recorded `git.repository_url` | | **pi** | standalone, OMP, and BB-managed Pi JSONL stores | session-header `cwd` | -| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | -| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | -| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | -| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | +| **opencode** | `~/.local/share/opencode/opencode.db` (sqlite) | `session.directory` / `session_v2.directory` | +| **grok** | `~/.grok/sessions///` | `summary.json` `cwd` + `git_remotes` | +| **cursor CLI** | `~/.cursor/chats///` | `meta.json` `cwd` | +| **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks | Claude collection covers `$CLAUDE_CONFIG_DIR/projects` alongside the default store, so a relocated config dir does not hide its sessions. The variable is read from backpass's own environment: if you reach that profile through an alias that only prefixes `claude`, set it for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it). -Pi collection covers standalone sessions under `~/.pi/agent/sessions/`, OMP sessions under -`~/.omp/agent/sessions/`, and BB-managed Pi sessions under `~/.bb/pi-bridge-sessions/`. It -also honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and -`BB_PI_BRIDGE_SESSION_DIR` when they are set in backpass's environment. When roots -overlap, backpass scans every applicable layout and reads each JSONL file once. +Pi collection covers standalone sessions under `~/.pi/agent/sessions/` and BB-managed Pi +sessions under `~/.bb/pi-bridge-sessions/`. It also honors `PI_CODING_AGENT_DIR`, +`PI_CODING_AGENT_SESSION_DIR`, `BB_DATA_DIR`, and `BB_PI_BRIDGE_SESSION_DIR` when they are +set in backpass's environment. When roots overlap, backpass scans every applicable layout +and reads each JSONL file once. + +OMP (Oh My Pi) collection is **off by default**. Set `discovery.includeOmp` to `true` in +`.backpassrc.json` or your [personal config](#configuration) to also read +`~/.omp/agent/sessions/` through the Pi adapter: + +```json +{ "discovery": { "includeOmp": true } } +``` + +Then run `backpass scan --harness pi` (or a normal `backpass` run). The setting also +applies to configured SSH hosts. For `--scope user`, set it in `user.discovery` and +include `"pi"` in `user.discovery.harnesses`; user scope otherwise collects only Claude +and Codex. Explicit Pi store environment overrides still work without this setting; +it controls only the additional default OMP store, not the harness backpass invokes. OMP nests subagent JSONL files below each parent session, and a subagent's own subagents one level further down. Backpass analyzes each file separately, but uses the root session as @@ -817,6 +831,7 @@ CLI flags on top: "since": "30d", "worktreeGlobs": [], "cloneRoots": [], + "includeOmp": false, "minUserTurns": 2 }, "jobs": 4 diff --git a/src/config.js b/src/config.js index 316997ab..28af46ba 100644 --- a/src/config.js +++ b/src/config.js @@ -119,6 +119,8 @@ export const DEFAULT_CONFIG = { * is refused by name. Absolute paths; `~` is expanded. */ opencodeStores: [], + /** Include Oh My Pi's default session store in Pi discovery; opt-in only. */ + includeOmp: false, minUserTurns: 2, includeCursorIde: false, }, @@ -467,6 +469,9 @@ function validate(config, { kind = "project", repoRoot = null } = {}) { } config.discovery.harnesses = config.discovery.harnesses.filter((h) => known.has(h)); parseSince(config.discovery.since); + if (typeof config.discovery.includeOmp !== "boolean") { + throw new UserError("config.discovery.includeOmp must be a boolean"); + } if (!Array.isArray(config.discovery.cloneRoots) || config.discovery.cloneRoots.some((p) => typeof p !== "string")) { throw new UserError("config.discovery.cloneRoots must be an array of paths"); } diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 70b877a8..8978c5c1 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -17,7 +17,8 @@ import { /** * Pi writes standalone sessions under * `~/.pi/agent/sessions//_.jsonl`. omp (Oh My Pi) uses the - * same JSONL shape under `~/.omp/agent/sessions/` and honors `PI_CODING_AGENT_DIR`, but + * same JSONL shape under `~/.omp/agent/sessions/` (opt-in via `discovery.includeOmp`) + * and honors `PI_CODING_AGENT_DIR`, but * prepends a fixed-width `{type:"title"}` record, so the `{type:"session", cwd, id}` * entry is line 2 there. omp also writes subagent transcripts one level deeper, at * `//.jsonl`, and their own subagents at @@ -56,12 +57,14 @@ function realpathOrResolve(value) { } } -function storeSpecs() { +function storeSpecs(config) { const specs = [ { path: storeRoot(), direct: false, nested: true }, - { path: home(".omp", "agent", "sessions"), direct: false, nested: true }, { path: home(".bb", "pi-bridge-sessions"), direct: true, nested: false }, ]; + if (config?.discovery?.includeOmp === true) { + specs.push({ path: home(".omp", "agent", "sessions"), direct: false, nested: true }); + } const piAgentDir = expandEnvPath(process.env.PI_CODING_AGENT_DIR); if (piAgentDir) specs.push({ path: path.join(piAgentDir, "sessions"), direct: false, nested: true }); const piSessionDir = expandEnvPath(process.env.PI_CODING_AGENT_SESSION_DIR); @@ -85,14 +88,16 @@ function storeSpecs() { return [...unique.values()]; } -export function storeRoots() { - return storeSpecs().map((spec) => spec.path); +/** @param {{ discovery?: { includeOmp?: boolean } }} [config] */ +export function storeRoots(config) { + return storeSpecs(config).map((spec) => spec.path); } -export function enumerate() { +/** @param {{ config?: { discovery?: { includeOmp?: boolean } }, cutoffMs?: number | null, repo?: object }} [options] */ +export function enumerate({ config } = {}) { const out = []; const seen = new Set(); - for (const spec of storeSpecs()) { + for (const spec of storeSpecs(config)) { const files = [ ...(spec.direct ? listFiles(spec.path, ".jsonl") : []), ...(spec.nested ? listDirs(spec.path).flatMap((dir) => sessionFiles(dir, SUBAGENT_DEPTH)) : []), diff --git a/src/discovery/hosts.js b/src/discovery/hosts.js index 7322a524..8fd53786 100644 --- a/src/discovery/hosts.js +++ b/src/discovery/hosts.js @@ -216,10 +216,10 @@ async function locate(entry, controlPath) { /** * Discover on every configured host, fail-soft per host. * - * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string }} options + * @param {{ hosts: object[], harnesses: string[], cutoffMs: number | null, controlPath: string, includeOmp?: boolean }} options * @returns {Promise} one result per host, in configured order */ -export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) { +export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath, includeOmp = false }) { const results = []; for (const entry of hosts) { const result = emptyHostResult(entry); @@ -243,7 +243,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) result.error = `failed to start ssh control master: ${masterFailure.message}`; } else { result.master = masterCall.master; - await collectOneHost(entry, { harnesses, cutoffMs }, result); + await collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result); } } catch (err) { if (err instanceof UserError) { @@ -272,7 +272,7 @@ export async function collectHosts({ hosts, harnesses, cutoffMs, controlPath }) return results; } -async function collectOneHost(entry, { harnesses, cutoffMs }, result) { +async function collectOneHost(entry, { harnesses, cutoffMs, includeOmp }, result) { const located = await locate(entry, result.master.controlPath); if (located.failure) { result.error = located.failure.message; @@ -318,7 +318,7 @@ async function collectOneHost(entry, { harnesses, cutoffMs }, result) { } const program = buildProbeProgram( - { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs }, + { protocol: PROTOCOL, op: "discover", harnesses: selected, cutoffMs, includeOmp }, { env: entry.env, }, diff --git a/src/discovery/index.js b/src/discovery/index.js index e55c508a..b078e9ce 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -154,6 +154,7 @@ export async function discoverTranscripts({ hosts, harnesses: selected.filter((h) => getAdapter(h)), cutoffMs, + includeOmp: config.discovery.includeOmp, controlPath: createControlPath(), }); remoteMasters.push(...collected.map((result) => result.master).filter(Boolean)); diff --git a/src/discovery/remote/probe.js b/src/discovery/remote/probe.js index 145460bd..6aa41c51 100644 --- a/src/discovery/remote/probe.js +++ b/src/discovery/remote/probe.js @@ -104,7 +104,7 @@ async function descriptorFrom(adapter, row, id) { }; } -async function discoverHarness(adapter, { cutoffMs }) { +async function discoverHarness(adapter, { cutoffMs, includeOmp }) { const stats = { scanned: 0, classified: 0, self: 0, error: null }; const out = []; const warnings = []; @@ -127,7 +127,7 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } - const candidates = adapter.enumerate({ cutoffMs }); + const candidates = adapter.enumerate({ cutoffMs, config: { discovery: { includeOmp } } }); const scanContext = adapter.createScanContext?.(); for (const candidate of candidates) { if (cutoffMs && candidate.mtimeMs < cutoffMs) continue; @@ -162,8 +162,8 @@ async function discoverHarness(adapter, { cutoffMs }) { return { stats, descriptors: out, warnings }; } -/** @param {{ harnesses?: string[], cutoffMs?: number | null }} request */ -export async function discover({ harnesses = [], cutoffMs = null } = {}) { +/** @param {{ harnesses?: string[], cutoffMs?: number | null, includeOmp?: boolean }} request */ +export async function discover({ harnesses = [], cutoffMs = null, includeOmp = false } = {}) { const harnessStats = Object.create(null); const descriptors = []; const warnings = []; @@ -181,7 +181,7 @@ export async function discover({ harnesses = [], cutoffMs = null } = {}) { continue; } try { - const result = await discoverHarness(adapter, { cutoffMs }); + const result = await discoverHarness(adapter, { cutoffMs, includeOmp }); harnessStats[harness] = result.stats; descriptors.push(...result.descriptors); warnings.push(...result.warnings); diff --git a/test/adapters.test.js b/test/adapters.test.js index 2b4fa11e..a226b63b 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -360,9 +360,26 @@ test("pi adapter enumerates standalone and BB-managed session roots without dupl }); withPiStoreEnv({ homeDir: fakeHome, piAgentDir, piSessionDir, bbDataDir, bridgeDir }, () => { + for (const config of [undefined, { discovery: { includeOmp: false } }]) { + assert.deepEqual( + pi + .enumerate({ config }) + .map((candidate) => path.basename(candidate.path)) + .sort(), + [ + "custom-agent.jsonl", + "custom-data.jsonl", + "custom-session.jsonl", + "default-bb.jsonl", + "direct-override.jsonl", + "standalone.jsonl", + ].sort(), + "OMP's default store is not read unless explicitly enabled", + ); + } assert.deepEqual( pi - .enumerate() + .enumerate({ config: { discovery: { includeOmp: true } } }) .map((candidate) => path.basename(candidate.path)) .sort(), [ diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index 6acdd90d..bfa99145 100644 --- a/test/analyze-reuse.test.js +++ b/test/analyze-reuse.test.js @@ -334,6 +334,10 @@ test("OMP analysis persists parent observer identity and fold restores it for le writeOmpTranscript(path.join(sessionRoot, `${parentName}.jsonl`), "parent-native"); writeOmpTranscript(path.join(sessionRoot, parentName, "Subagent.jsonl"), "child-native"); + const disabled = runAnalyze(dir, home); + assert.equal(disabled.status, 0, disabled.output); + assert.equal(JSON.parse(disabled.stdout).transcripts, 0, "the default CLI run does not read OMP sessions"); + fs.writeFileSync(path.join(dir, ".backpassrc.json"), JSON.stringify({ discovery: { includeOmp: true } })); const analyzed = runAnalyze(dir, home); assert.equal(analyzed.status, 0, analyzed.output); @@ -345,7 +349,7 @@ test("OMP analysis persists parent observer identity and fold restores it for le process.env.USERPROFILE = home; try { const repo = resolveRepo(dir); - const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all" } }); + const config = loadConfig(dir, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); const state = new State(dir).ensure(); config.state = state; const ctx = { repo, config, scope: null, strict: false, limit: null }; diff --git a/test/config.test.js b/test/config.test.js index d687d3cd..f7802a2c 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -178,6 +178,15 @@ test("--include-cursor-ide is the only way the deferred store is scanned", () => assert.ok(config.discovery.harnesses.includes("cursor-ide")); }); +test("OMP discovery rejects non-boolean opt-ins rather than treating them as enabled", () => { + for (const includeOmp of ["true", 1, null, []]) { + assert.throws( + () => loadConfig(tempRepo({ discovery: { includeOmp } })), + /config\.discovery\.includeOmp must be a boolean/, + ); + } +}); + test("unknown harness names are dropped rather than failing the run", () => { const config = loadConfig(tempRepo({ discovery: { harnesses: ["claude", "not-a-harness"] } })); assert.deepEqual(config.discovery.harnesses, ["claude"]); diff --git a/test/interaction.test.js b/test/interaction.test.js index 226941c1..465ea1c9 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -805,10 +805,10 @@ test("OMP subagents share their parent identity and refresh cached relations", a const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); config.state = new State(repo).ensure(); const cache = config.state.readScanCache(); - for (const candidate of pi.enumerate()) { + for (const candidate of pi.enumerate({ config })) { const descriptor = pi.classify(candidate); delete descriptor.parentSessionId; delete descriptor.parentSessionPath; @@ -839,6 +839,9 @@ test("OMP subagents share their parent identity and refresh cached relations", a second.transcripts.find((transcript) => transcript.nativeId === "child-native").corroborationIdentity, parent.identity, ); + config.discovery.includeOmp = false; + const disabled = await discoverTranscripts({ repo: repository, config }); + assert.deepEqual(disabled.transcripts, [], "opting out must not replay cached OMP descriptors"); } finally { if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; @@ -861,7 +864,7 @@ test("Pi child cache is invalidated when its parent session appears", async () = const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); config.state = new State(repo).ensure(); const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; @@ -910,7 +913,7 @@ test("nested OMP subagents share the root identity once the root session appears const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); config.state = new State(repo).ensure(); config.gapLedgerMaxAge = "all"; const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; @@ -997,7 +1000,7 @@ test("OMP subagents running in another repo cwd still share the root identity", const prevHome = process.env.HOME; process.env.HOME = home; try { - const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all" } }); + const config = loadConfig(repo, { discovery: { harnesses: ["pi"], since: "all", includeOmp: true } }); config.state = new State(repo).ensure(); const repository = { name: "demo", root: repo, worktrees: [repo], remotes: [] }; const result = await discoverTranscripts({ repo: repository, config }); diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index fd509e0e..2ea5c9bb 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -68,11 +68,17 @@ test("remote OMP subagents keep their parent's corroboration identity", async () for (const key of piEnv) delete process.env[key]; let result; try { - result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + const disabled = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => discoverProject(s.repoRoot, { discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all" }, }), ); + assert.deepEqual(disabled.transcripts, [], "the remote OMP store is opt-in too"); + result = await withRemoteEnv({ localHome: s.localHome, hosts: s.hosts }, () => + discoverProject(s.repoRoot, { + discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, + }), + ); } finally { for (const key of piEnv) { if (previous[key] === undefined) delete process.env[key]; From 0e7fd41127937083d990ae5d40791051cc6c2b8f Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 11:47:31 -0500 Subject: [PATCH 11/17] no-mistakes(review): Fix OMP observer deduplication, native ownership, and descendant classification --- README.md | 9 +- src/discovery/adapters/pi.js | 12 +- src/discovery/index.js | 15 +- src/fold.js | 65 +++++-- src/gap-ledger.js | 90 ++++++++-- src/interaction.js | 11 +- src/proposal.js | 39 +++-- test/adapters.test.js | 31 ++++ test/analyze-reuse.test.js | 43 +++++ test/fold.test.js | 10 +- test/gap-ledger.test.js | 214 +++++++++++++++++++++++ test/interaction.test.js | 83 +++++++++ test/nested.test.js | 317 +++++++++++++++++++++++++++++++++- test/proposal.test.js | 52 ++++++ test/remote-discovery.test.js | 157 +++++++++++++++-- 15 files changed, 1076 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index e6ca82e9..57505f59 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,12 @@ and Codex. Explicit Pi store environment overrides still work without this setti it controls only the additional default OMP store, not the harness backpass invokes. OMP nests subagent JSONL files below each parent session, and a subagent's own subagents -one level further down. Backpass analyzes each file separately, but uses the root session as -their shared corroboration source; a session and its subagents cannot count as independent -sessions. +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. 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. diff --git a/src/discovery/adapters/pi.js b/src/discovery/adapters/pi.js index 8978c5c1..afda0186 100644 --- a/src/discovery/adapters/pi.js +++ b/src/discovery/adapters/pi.js @@ -33,7 +33,7 @@ import { */ export const name = "pi"; -export const cacheVersion = 4; +export const cacheVersion = 5; const SUBAGENT_DEPTH = 2; @@ -108,7 +108,13 @@ export function enumerate({ config } = {}) { 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; @@ -194,7 +200,7 @@ export function classify(candidate, options = {}) { 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)) { diff --git a/src/discovery/index.js b/src/discovery/index.js index b078e9ce..d3d38100 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -258,6 +258,11 @@ 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 canonicalObserver = (identity) => { + while (observerAliases.has(identity)) identity = observerAliases.get(identity); + return identity; + }; let write = 0; for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.nativeId}`; @@ -265,13 +270,21 @@ function dropCrossHostDuplicates(transcripts) { if (previous && previous.host !== (transcript.host || null)) { const host = transcript.host || "local"; drops.set(host, (drops.get(host) || 0) + 1); + const droppedObserver = transcript.corroborationIdentity; + const retainedObserver = canonicalObserver(previous.corroborationIdentity); + if (droppedObserver !== retainedObserver && !observerAliases.has(droppedObserver)) { + observerAliases.set(droppedObserver, retainedObserver); + } continue; } - if (!previous) seen.set(key, { host: transcript.host || null }); + if (!previous) seen.set(key, transcript); transcripts[write] = transcript; write += 1; } transcripts.length = write; + for (const transcript of transcripts) { + transcript.corroborationIdentity = canonicalObserver(transcript.corroborationIdentity); + } return drops; } diff --git a/src/fold.js b/src/fold.js index 2ae0152e..83d9006f 100644 --- a/src/fold.js +++ b/src/fold.js @@ -108,11 +108,11 @@ export function foldEvidence( const issuedSources = disambiguateSourceLabels([ ...usable.map((record) => ({ source: gapSource(record.transcript), - identity: corroborationIdentityOf(record.transcript), + identity: record.transcript.identity || record.transcript.id, })), ...persistedObservations.map((observation) => ({ source: observation?.source, - identity: observation?.sessionId, + identity: observation?.sourceSessionId || observation?.sessionId, })), ]); const recordSources = issuedSources.slice(0, usable.length); @@ -165,6 +165,8 @@ export function foldEvidence( recurrenceRisk: gap.recurrenceRisk, source, sessionId: corroborationIdentity, + sourceSessionId: sessionIdentity, + sightingIds: [sessionIdentity], domain: gap.domain === "orchestration" ? "orchestration" : "project", project: record.transcript.project || null, projectRoot: record.transcript.projectRoot || null, @@ -268,7 +270,9 @@ 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 = []; @@ -340,6 +344,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, @@ -350,24 +358,34 @@ 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?.sightingIds || []), + ]); + for (const [index, observation] of persistedObservations.entries()) { + if (observation?.unattributedSightings) { + delete summary.sourceSessions[normalizeSourceLabel(observationSources[index])]; + } + } } 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; } @@ -423,9 +441,18 @@ 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, @@ -439,6 +466,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); } @@ -539,6 +569,11 @@ function observationDomain(obs) { return obs?.domain === "orchestration" ? "orchestration" : "project"; } +function gapOwnerOf(items, route) { + if (items.some((item) => item.unattributedSightings || !item.sightingIds.length)) return null; + return route.ownerOf(items.flatMap((item) => item.sightingIds)); +} + /** * 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. @@ -546,11 +581,11 @@ function observationDomain(obs) { 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; } diff --git a/src/gap-ledger.js b/src/gap-ledger.js index d38ca9f1..99d7614b 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -66,8 +66,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. * @@ -75,10 +75,10 @@ export function emptyGapLedger() { * cross-machine corroboration actually is: two machines hitting one gap, named. */ export function gapSource(transcript = {}) { - const startedAt = transcript.corroborationStartedAt ?? transcript.startedAt; + const startedAt = transcript.startedAt; const date = startedAt ? new Date(startedAt).toISOString().slice(0, 10) : "unknown date"; const host = transcript.host ? ` · ${transcript.host}` : ""; - const sourceId = transcript.corroborationNativeId || sessionSourceId(transcript); + const sourceId = sessionSourceId(transcript); return `${transcript.harness} · ${sourceId} · ${date}${host}`; } @@ -175,7 +175,27 @@ function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { ); } -function takePriorObservations(entry, sessionIdentity, aliases) { +function takePriorObservations(entry, sessionIdentity, aliases, transcript) { + const nativeIdentity = transcript.identity || transcript.id; + const current = entry.sessions[sessionIdentity]; + 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[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, ); @@ -188,6 +208,20 @@ function takePriorObservations(entry, sessionIdentity, aliases) { 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. @@ -237,6 +271,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry, sessionIdentity, sessionIdentityAliases(transcript, sessionIdentity, legacyIds), + transcript, ); const { priors, firstObservedAt } = prior; const coveredBySkill = gap.coveredBySkill || prior.coveredBySkill; @@ -258,8 +293,19 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { 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: @@ -291,13 +337,29 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); const aliases = sessionIdentityAliases(transcript, sessionIdentity, legacyIds); - if (aliases.length) selections.push({ transcript, sessionIdentity, aliases }); + const nativeIdentity = transcript.identity || transcript.id; + const ordinaryNative = + transcript.harness !== "pi" && !transcript.parentSessionId && sessionIdentity === nativeIdentity; + if (aliases.length || ordinaryNative) selections.push({ transcript, sessionIdentity, aliases, ordinaryNative }); } for (const entry of Object.values(ledger.entries)) { - for (const { transcript, sessionIdentity, aliases } of selections) { + for (const { transcript, sessionIdentity, aliases, ordinaryNative } of selections) { + 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); + 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; @@ -305,8 +367,9 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne entry.sessions[sessionIdentity] = { ...current, ...(firstObservedAt ? { firstObservedAt } : {}), - sessionStartedAt: transcript.corroborationStartedAt ?? transcript.startedAt ?? current.sessionStartedAt ?? null, - source: gapSource(transcript), + sightingIds: [...new Set(priors.flatMap((observation) => observation.sightingIds || []))], + sightingQuotes: [...new Set(priors.flatMap(sightingQuotesOf))], + ...(priors.some(hasUnattributedSightings) ? { unattributedSightings: true } : {}), phrasings: [ ...new Set([ entry.proposedInstruction, @@ -397,8 +460,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)) @@ -464,6 +531,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 3fd28e9d..0e04a894 100644 --- a/src/interaction.js +++ b/src/interaction.js @@ -6,7 +6,7 @@ * * Non-interactive is detected best-effort from per-harness metadata (codex * `originator: codex_exec` / `source: exec`, claude `entrypoint` values that start - * with `sdk`, an OpenCode child `parent_id`, an OMP subagent's `parentSessionId`, + * 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). @@ -58,12 +58,13 @@ function claudeEntrypointIsNonInteractive(entrypoint) { /** * Map a discovered transcript (or adapter descriptor) onto the two public categories. - * A `parentSessionId` (an OMP subagent) is always non-interactive, even over a stamped - * label; otherwise an explicit `transcript.interaction` is trusted when it is already one - * of the two labels. + * An OMP subagent is always non-interactive, even over a stamped label; otherwise an + * explicit `transcript.interaction` is trusted when it is already one of the two labels. */ export function classifyInteraction(transcript) { - if (transcript?.parentSessionId) return NON_INTERACTIVE; + 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 70115679..00742b80 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/test/adapters.test.js b/test/adapters.test.js index a226b63b..b4ccd788 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -307,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"); diff --git a/test/analyze-reuse.test.js b/test/analyze-reuse.test.js index bfa99145..f75bd67f 100644 --- a/test/analyze-reuse.test.js +++ b/test/analyze-reuse.test.js @@ -424,3 +424,46 @@ test("OMP analysis persists parent observer identity and fold restores it for le 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/fold.test.js b/test/fold.test.js index 57297010..1b2e122b 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -554,7 +554,7 @@ test("OMP subagents share corroboration while relevance remains per file", () => corroborationNativeId: "parent-native", corroborationStartedAt: startedAt, harness: "pi", - startedAt: startedAt + 1_000, + startedAt: startedAt + 86_400_000, interaction: "non-interactive", }; const independent = { @@ -582,7 +582,11 @@ test("OMP subagents share corroboration while relevance remains per file", () => const oneObserver = new Map(parentAndChild.instructions.map((row) => [row.instruction, row])); assert.equal(parentAndChild.gaps.length, 0, "parent and subagent cannot clear the two-session floor"); assert.equal(parentAndChild.totals.droppedGapSingletons, 1); - assert.equal(parentAndChild.sources.length, 1, "both files share one visible evidence source"); + assert.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"); @@ -595,7 +599,7 @@ test("OMP subagents share corroboration while relevance remains per file", () => const twoObservers = new Map(independentlyCorroborated.instructions.map((row) => [row.instruction, row])); assert.equal(independentlyCorroborated.gaps.length, 1); assert.equal(independentlyCorroborated.gaps[0].sessions, 2); - assert.equal(independentlyCorroborated.sources.length, 2); + assert.equal(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); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 844e9b30..4d0495bc 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -14,7 +14,9 @@ import { mergeGapEntries, pruneGapLedger, recordGapObservations, + normalizeGapLedgerSessions, } from "../src/gap-ledger.js"; +import { routingFor, workedPaths } from "../src/nested.js"; const MEMORY_PATH = "AGENTS.md"; const DAY = 86_400_000; @@ -242,6 +244,218 @@ test("a selected OMP child collapses legacy parent and child ledger sightings", assert.equal(summary.gaps.length, 0, "old parent and child keys still represent one observer"); assert.equal(summary.totals.droppedGapSingletons, 1); assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); + assert.deepEqual( + Object.values(h.state.readGapLedger().entries)[0].sessions[parentIdentity].sightingQuotes.sort(), + ["quote from pi-child", "quote from pi-parent"], + ); +}); + +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.deepEqual(summary.sourceSessions[shared.source].sort(), ["child-api", "child-web"]); + 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 replaced = ledgerGapObservations(ledger, MEMORY_PATH).find( + (observation) => observation.sessionId === "observer-a", + ); + assert.equal(replaced.sourceSessionId, "child-web"); + assert.deepEqual(replaced.sightingIds.sort(), ["child-api", "child-web"]); + assert.deepEqual(replaced.sightingQuotes.sort(), ["quote from child-api", "quote from child-web"]); +}); + +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", () => { diff --git a/test/interaction.test.js b/test/interaction.test.js index 465ea1c9..17199bbd 100644 --- a/test/interaction.test.js +++ b/test/interaction.test.js @@ -872,6 +872,11 @@ test("Pi child cache is invalidated when its parent session appears", async () = 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 }); @@ -895,6 +900,84 @@ test("Pi child cache is invalidated when its parent session appears", async () = } }); +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-")); diff --git a/test/nested.test.js b/test/nested.test.js index eedb2f83..ba5bba9f 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,320 @@ 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); + assert.equal(rootSummary.sourceSessions[observations[1].source], undefined); + 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 4b844201..b95a323e 100644 --- a/test/proposal.test.js +++ b/test/proposal.test.js @@ -595,6 +595,58 @@ 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("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 2ea5c9bb..678d26d5 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -5,7 +5,7 @@ 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 { disambiguateSourceLabels, gapSource, 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 +14,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,28 +42,37 @@ 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]; + } + } +} + test("remote OMP subagents keep their parent's corroboration identity", async () => { const s = scenario(); const sessionDir = path.join(s.remoteHome, ".omp", "agent", "sessions", "-repo-demo"); const parentName = "2026-08-27T00-00-00.000Z_parent-folder"; const parentPath = path.join(sessionDir, `${parentName}.jsonl`); const childPath = path.join(sessionDir, parentName, "Subagent.jsonl"); - const writeSession = (file, id, timestamp) => { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync( - file, - `${JSON.stringify({ type: "title", v: 1, title: "" })}\n` + - `${JSON.stringify({ - type: "session", - version: 3, - id, - timestamp, - cwd: s.remoteClone, - })}\n`, - ); - }; - writeSession(parentPath, "parent-native", "2026-08-27T00:00:00.000Z"); - writeSession(childPath, "child-native", "2026-08-27T00:01:00.000Z"); + 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]])); @@ -96,6 +107,118 @@ test("remote OMP subagents keep their parent's corroboration identity", async () 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, splitRoot.identity); + 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)); +}); + +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 = []; From 3c6cf61078698dd5c5fbc035bd5cf784d83abc83 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 14:41:25 -0500 Subject: [PATCH 12/17] no-mistakes(review): Correct observer canonicalization, native attribution, and selected provenance migration --- src/commands/propose.js | 3 +- src/discovery/index.js | 13 +++ src/fold.js | 2 +- src/gap-ledger.js | 37 ++++++- test/gap-ledger.test.js | 179 +++++++++++++++++++++++++++++++++- test/proposal.test.js | 79 +++++++++++++++ test/remote-discovery.test.js | 112 ++++++++++++++++++++- 7 files changed, 416 insertions(+), 9 deletions(-) diff --git a/src/commands/propose.js b/src/commands/propose.js index 04522c3e..0a25b4e9 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -48,13 +48,12 @@ 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(selectedByIdentity.keys()); + const selectedGapSessions = new Set(); const legacyIds = new Set(); for (const transcript of transcripts) { selectedGapSessions.add(corroborationIdentityOf(transcript)); const identities = identitiesByLegacyId.get(transcript.id); if (identities?.size === 1 && identities.has(transcriptIdentity(transcript))) { - selectedGapSessions.add(transcript.id); legacyIds.add(transcript.id); } } diff --git a/src/discovery/index.js b/src/discovery/index.js index d3d38100..7d9c22a8 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -282,6 +282,19 @@ function dropCrossHostDuplicates(transcripts) { write += 1; } transcripts.length = write; + const observers = new Map(); + for (const transcript of transcripts) { + const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; + const previous = observers.get(key); + if (!previous) observers.set(key, transcript); + else if (previous.host !== (transcript.host || null)) { + const observer = transcript.corroborationIdentity; + const retainedObserver = canonicalObserver(previous.corroborationIdentity); + if (observer !== retainedObserver && !observerAliases.has(observer)) { + observerAliases.set(observer, retainedObserver); + } + } + } for (const transcript of transcripts) { transcript.corroborationIdentity = canonicalObserver(transcript.corroborationIdentity); } diff --git a/src/fold.js b/src/fold.js index 83d9006f..fa626036 100644 --- a/src/fold.js +++ b/src/fold.js @@ -367,7 +367,7 @@ export function foldEvidence( summary.routedGaps = routedGaps; summary.sourceSessions = sourceIdentitiesOf(issuedSources, [ ...usable.map((record) => [record.transcript.identity || record.transcript.id]), - ...persistedObservations.map((observation) => observation?.sightingIds || []), + ...persistedObservations.map((observation) => [observation?.sourceSessionId || observation?.sessionId]), ]); for (const [index, observation] of persistedObservations.entries()) { if (observation?.unattributedSightings) { diff --git a/src/gap-ledger.js b/src/gap-ledger.js index 99d7614b..8cd60563 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -270,7 +270,9 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { const prior = takePriorObservations( entry, sessionIdentity, - sessionIdentityAliases(transcript, sessionIdentity, legacyIds), + sessionIdentityAliases(transcript, sessionIdentity, legacyIds).filter( + (identity) => !entry.sessions[identity]?.sourceSessionId, + ), transcript, ); const { priors, firstObservedAt } = prior; @@ -333,6 +335,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { */ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = new Set() } = {}) { const selections = []; + const observersByNative = new Map(); for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); @@ -340,11 +343,39 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne const nativeIdentity = transcript.identity || transcript.id; const ordinaryNative = transcript.harness !== "pi" && !transcript.parentSessionId && sessionIdentity === nativeIdentity; - if (aliases.length || ordinaryNative) selections.push({ transcript, sessionIdentity, aliases, ordinaryNative }); + const nativeAliases = [ + nativeIdentity, + ...(legacyIds.has(transcript.id) ? [transcript.id] : []), + ]; + for (const identity of nativeAliases) { + if (!observersByNative.has(identity)) observersByNative.set(identity, new Set()); + observersByNative.get(identity).add(sessionIdentity); + } + selections.push({ transcript, sessionIdentity, aliases, ordinaryNative }); } + const resolvesTo = (observation, sessionIdentity) => { + if (hasUnattributedSightings(observation)) return false; + const natives = new Set([observation.sourceSessionId, ...(observation.sightingIds || [])]); + return [...natives].every((native) => { + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + }); + }; for (const entry of Object.values(ledger.entries)) { - for (const { transcript, sessionIdentity, aliases, ordinaryNative } of selections) { + for (const { transcript, sessionIdentity, aliases: nativeAliases, ordinaryNative } of selections) { + const aliases = nativeAliases.filter((identity) => { + const prior = entry.sessions[identity]; + return !prior?.sourceSessionId || 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; diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 4d0495bc..dbf6e53f 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -115,6 +115,183 @@ 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("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({ + id: "H", identity: "H", 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])]); @@ -364,7 +541,7 @@ test("native sightings union across recording, migration and consolidation with const summary = foldEvidence([], { minGapEvidence: 2, gapObservations: observations, route }); assert.equal(summary.gaps.length, 1); assert.equal(summary.gaps[0].sessions, 2); - assert.deepEqual(summary.sourceSessions[shared.source].sort(), ["child-api", "child-web"]); + 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 }, diff --git a/test/proposal.test.js b/test/proposal.test.js index b95a323e..cb3fb87b 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, 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"; @@ -647,6 +649,83 @@ test("native OMP quote labels still measure one proposal observer per root", () } }); +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("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 678d26d5..f9934263 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -4,8 +4,8 @@ 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, recordGapObservations } 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"; @@ -177,6 +177,114 @@ test("a copied OMP root and retained remote descendants count as one observer wi assert.ok(roots.every((transcript) => transcript.corroborationIdentity !== independent.identity)); }); +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 }); + 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"); From e66cc746db6d3692de2f84bf92f3fc176b781464 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 14:52:05 -0500 Subject: [PATCH 13/17] no-mistakes(review): Fix conflicting cross-host descendant family corroboration --- README.md | 7 +- src/discovery/index.js | 24 ++++-- test/remote-discovery.test.js | 141 +++++++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 57505f59..036e09cc 100644 --- a/README.md +++ b/README.md @@ -256,9 +256,10 @@ OMP nests subagent JSONL files below each parent session, and a subagent's own s 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. Nested descendants are non-interactive even when -ancestor headers are unavailable; root provenance is linked only when those headers can -be read. +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. diff --git a/src/discovery/index.js b/src/discovery/index.js index 7d9c22a8..2e7ffd7d 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -271,8 +271,13 @@ function dropCrossHostDuplicates(transcripts) { const host = transcript.host || "local"; drops.set(host, (drops.get(host) || 0) + 1); const droppedObserver = transcript.corroborationIdentity; - const retainedObserver = canonicalObserver(previous.corroborationIdentity); - if (droppedObserver !== retainedObserver && !observerAliases.has(droppedObserver)) { + const retainedObserver = previous.corroborationIdentity; + if ( + transcript.parentSessionId && + previous.parentSessionId && + droppedObserver !== retainedObserver && + !observerAliases.has(droppedObserver) + ) { observerAliases.set(droppedObserver, retainedObserver); } continue; @@ -286,10 +291,19 @@ function dropCrossHostDuplicates(transcripts) { for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; const previous = observers.get(key); - if (!previous) observers.set(key, transcript); - else if (previous.host !== (transcript.host || null)) { + if ( + !previous || + (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)) { const observer = transcript.corroborationIdentity; - const retainedObserver = canonicalObserver(previous.corroborationIdentity); + const retainedObserver = previous.corroborationIdentity; if (observer !== retainedObserver && !observerAliases.has(observer)) { observerAliases.set(observer, retainedObserver); } diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index f9934263..f69ac2dd 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -65,6 +65,44 @@ async function withRemotePiEnv(scenario, fn) { } } +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"); @@ -170,13 +208,114 @@ test("a copied OMP root and retained remote descendants count as one observer wi 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, splitRoot.identity); + 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]) { + 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); + }); + } + } +} + 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"); From b3c43cf74c9b0561d64c497011449fb2f1ac27e7 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 15:03:21 -0500 Subject: [PATCH 14/17] no-mistakes(review): Fix copied-descendant corroboration and historical ledger provenance --- src/commands/propose.js | 12 +++- src/discovery/index.js | 24 ++++---- src/fold.js | 11 +++- test/fold.test.js | 63 +++++++++++++++++++++ test/gap-ledger.test.js | 102 ++++++++++++++++++++++++++++++++++ test/remote-discovery.test.js | 101 +++++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 16 deletions(-) diff --git a/src/commands/propose.js b/src/commands/propose.js index 0a25b4e9..bdb3e8b8 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -49,9 +49,13 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans identitiesByLegacyId.get(legacyId).add(transcriptIdentity(record.transcript)); } 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))) { legacyIds.add(transcript.id); @@ -98,9 +102,11 @@ 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).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/discovery/index.js b/src/discovery/index.js index 2e7ffd7d..976edf53 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -263,6 +263,12 @@ function dropCrossHostDuplicates(transcripts) { while (observerAliases.has(identity)) identity = observerAliases.get(identity); return identity; }; + const aliasObserver = (observer, retainedObserver) => { + const retained = canonicalObserver(retainedObserver); + if (observer !== retained && !observerAliases.has(observer)) { + observerAliases.set(observer, retained); + } + }; let write = 0; for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.nativeId}`; @@ -270,15 +276,13 @@ function dropCrossHostDuplicates(transcripts) { if (previous && previous.host !== (transcript.host || null)) { const host = transcript.host || "local"; drops.set(host, (drops.get(host) || 0) + 1); - const droppedObserver = transcript.corroborationIdentity; - const retainedObserver = previous.corroborationIdentity; if ( - transcript.parentSessionId && - previous.parentSessionId && - droppedObserver !== retainedObserver && - !observerAliases.has(droppedObserver) + (transcript.parentSessionId || + (transcript.harness === "pi" && transcript.interactionSignals.source === "subagent")) && + (previous.parentSessionId || + (previous.harness === "pi" && previous.interactionSignals.source === "subagent")) ) { - observerAliases.set(droppedObserver, retainedObserver); + aliasObserver(transcript.corroborationIdentity, previous.corroborationIdentity); } continue; } @@ -302,11 +306,7 @@ function dropCrossHostDuplicates(transcripts) { const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; const previous = observers.get(key); if (previous.host !== (transcript.host || null)) { - const observer = transcript.corroborationIdentity; - const retainedObserver = previous.corroborationIdentity; - if (observer !== retainedObserver && !observerAliases.has(observer)) { - observerAliases.set(observer, retainedObserver); - } + aliasObserver(transcript.corroborationIdentity, previous.corroborationIdentity); } } for (const transcript of transcripts) { diff --git a/src/fold.js b/src/fold.js index fa626036..2c65be19 100644 --- a/src/fold.js +++ b/src/fold.js @@ -104,7 +104,16 @@ 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 ?? []).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), diff --git a/test/fold.test.js b/test/fold.test.js index 1b2e122b..08410860 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -145,6 +145,69 @@ 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("instructions with no evidence still appear - they are the removal candidates", () => { const summary = foldEvidence([record("s1", { positive: [{ instruction: "AG-001", quote: "q" }] })], { memoryFile }); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index dbf6e53f..d11f612d 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -17,6 +17,8 @@ import { 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; @@ -245,6 +247,106 @@ test("foldForRun leaves ambiguous and incompletely selected old observers outsid } }); +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("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" }); diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index f69ac2dd..e50b63ee 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -316,6 +316,107 @@ for (const remoteOnly of [false, true]) { } } +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"); From b5c2da81b624c4ca90c191b9f4ea40eb1c3467d5 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 15:28:16 -0500 Subject: [PATCH 15/17] no-mistakes(review): Reconcile copied families and preserve validated evidence provenance --- src/commands/propose.js | 10 +- src/discovery/index.js | 17 ++- src/fold.js | 23 ++-- src/gap-ledger.js | 124 ++++++++++++++++----- test/fold.test.js | 81 ++++++++++++++ test/gap-ledger.test.js | 200 +++++++++++++++++++++++++++++++--- test/proposal.test.js | 106 +++++++++++++++++- test/remote-discovery.test.js | 151 +++++++++++++++++++++++++ 8 files changed, 655 insertions(+), 57 deletions(-) diff --git a/src/commands/propose.js b/src/commands/propose.js index bdb3e8b8..3d2acf9a 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -5,6 +5,7 @@ import { normalizeGapLedgerSessions, pruneGapLedger, recordGapObservations, + resolveGapObservationObserver, } from "../gap-ledger.js"; import { synthesizeProposal } from "../synthesize.js"; import { ProposalViolation } from "../proposal.js"; @@ -59,6 +60,8 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans const identities = identitiesByLegacyId.get(transcript.id); if (identities?.size === 1 && identities.has(transcriptIdentity(transcript))) { legacyIds.add(transcript.id); + if (!selectedObserversByNative.has(transcript.id)) selectedObserversByNative.set(transcript.id, new Set()); + selectedObserversByNative.get(transcript.id).add(corroborationIdentityOf(transcript)); } } const relevant = []; @@ -85,7 +88,7 @@ export async function foldForRun(ctx, memoryFile, memoryHash, skills = [], trans const ledger = state.readGapLedger(); normalizeGapLedgerSessions(ledger, transcripts, { legacyIds }); - recordGapObservations(ledger, relevant, { skills, 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 @@ -102,7 +105,10 @@ 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) => { + 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)); diff --git a/src/discovery/index.js b/src/discovery/index.js index 976edf53..b60d5772 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -259,14 +259,16 @@ 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 (observer !== retained && !observerAliases.has(observer)) { - observerAliases.set(observer, retained); + if (canonical !== retained) { + observerAliases.set(canonical, retained); } }; let write = 0; @@ -283,6 +285,10 @@ function dropCrossHostDuplicates(transcripts) { (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; } @@ -291,13 +297,14 @@ function dropCrossHostDuplicates(transcripts) { write += 1; } transcripts.length = write; - const observers = new Map(); + const observers = new Map(observerCorrespondences); for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; const previous = observers.get(key); if ( !previous || - (previous.host === (transcript.host || null) && !previous.parentSessionId && transcript.parentSessionId) + (!observerCorrespondences.has(key) && + previous.host === (transcript.host || null) && !previous.parentSessionId && transcript.parentSessionId) ) { observers.set(key, transcript); } @@ -305,7 +312,7 @@ function dropCrossHostDuplicates(transcripts) { for (const transcript of transcripts) { const key = `${transcript.harness}\n${transcript.corroborationNativeId}`; const previous = observers.get(key); - if (previous.host !== (transcript.host || null)) { + if (previous.host !== (transcript.host || null) && !observerAliases.has(transcript.corroborationIdentity)) { aliasObserver(transcript.corroborationIdentity, previous.corroborationIdentity); } } diff --git a/src/fold.js b/src/fold.js index 2c65be19..57e8e2f8 100644 --- a/src/fold.js +++ b/src/fold.js @@ -10,6 +10,7 @@ import { disambiguateSourceLabels, gapSource, normalizeSourceLabel, + resolveGapObservationObserver, } from "./gap-ledger.js"; import { crossSurfaceDuplicates } from "./overlap.js"; import { corroborationIdentityOf } from "./transcript.js"; @@ -110,10 +111,15 @@ export function foldEvidence( if (!observersByNative.has(nativeIdentity)) observersByNative.set(nativeIdentity, new Set()); observersByNative.get(nativeIdentity).add(corroborationIdentityOf(record.transcript)); } - const persistedObservations = (gapObservations ?? []).filter((observation) => { - const observers = observersByNative.get(observation?.sourceSessionId); - return !observers || (observers.size === 1 && observers.has(observation?.sessionId)); - }); + 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), @@ -376,13 +382,10 @@ export function foldEvidence( summary.routedGaps = routedGaps; summary.sourceSessions = sourceIdentitiesOf(issuedSources, [ ...usable.map((record) => [record.transcript.identity || record.transcript.id]), - ...persistedObservations.map((observation) => [observation?.sourceSessionId || observation?.sessionId]), + ...persistedObservations.map((observation) => + observation?.unattributedSightings ? [] : [observation?.sourceSessionId || observation?.sessionId], + ), ]); - for (const [index, observation] of persistedObservations.entries()) { - if (observation?.unattributedSightings) { - delete summary.sourceSessions[normalizeSourceLabel(observationSources[index])]; - } - } } return summary; } diff --git a/src/gap-ledger.js b/src/gap-ledger.js index 8cd60563..0a5d30ef 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -175,9 +175,62 @@ function sessionIdentityAliases(transcript, sessionIdentity, legacyIds) { ); } -function takePriorObservations(entry, sessionIdentity, aliases, transcript) { +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; - const current = entry.sessions[sessionIdentity]; + 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 && @@ -196,7 +249,7 @@ function takePriorObservations(entry, sessionIdentity, aliases, transcript) { observation.sightingIds = [...new Set([...(observation.sightingIds || []), nativeIdentity])]; observation.source = gapSource(transcript); } - const priors = [entry.sessions[sessionIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( + const priors = [entry.sessions[destinationIdentity], ...aliases.map((identity) => entry.sessions[identity])].filter( Boolean, ); const firstObservedAt = priors @@ -227,11 +280,15 @@ function sightingQuotesOf(observation) { * session seen again replaces its own observation and keeps its first-seen timestamp. * A legacy `transcript.id` key is that session's only when it is in `legacyIds`. * - * @param {{ now?: Date, skills?: unknown[], legacyIds?: Set }} [options] + * @param {{ now?: Date, skills?: unknown[], legacyIds?: Set, transcripts?: object[] }} [options] */ export function recordGapObservations(ledger, evidenceRecords, 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; @@ -267,13 +324,31 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry.proposedInstruction = gap.proposedInstruction; } } + 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, - sessionIdentityAliases(transcript, sessionIdentity, legacyIds).filter( - (identity) => !entry.sessions[identity]?.sourceSessionId, + (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; @@ -285,7 +360,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { gap.proposedInstruction, ]), ]; - entry.sessions[sessionIdentity] = { + entry.sessions[destinationIdentity] = { firstObservedAt: firstObservedAt || observedAt, observedAt, sessionStartedAt: @@ -335,7 +410,7 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { */ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = new Set() } = {}) { const selections = []; - const observersByNative = new Map(); + const observersByNative = selectedNativeObservers(transcripts, legacyIds); for (const transcript of transcripts) { if (!(transcript?.corroborationIdentity || transcript?.identity || transcript?.id)) continue; const sessionIdentity = corroborationIdentityOf(transcript); @@ -343,30 +418,29 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne const nativeIdentity = transcript.identity || transcript.id; const ordinaryNative = transcript.harness !== "pi" && !transcript.parentSessionId && sessionIdentity === nativeIdentity; - const nativeAliases = [ - nativeIdentity, - ...(legacyIds.has(transcript.id) ? [transcript.id] : []), - ]; - for (const identity of nativeAliases) { - if (!observersByNative.has(identity)) observersByNative.set(identity, new Set()); - observersByNative.get(identity).add(sessionIdentity); - } selections.push({ transcript, sessionIdentity, aliases, ordinaryNative }); } - const resolvesTo = (observation, sessionIdentity) => { - if (hasUnattributedSightings(observation)) return false; - const natives = new Set([observation.sourceSessionId, ...(observation.sightingIds || [])]); - return [...natives].every((native) => { - const observers = observersByNative.get(native); - return observers?.size === 1 && observers.has(sessionIdentity); - }); - }; + 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 || resolvesTo(prior, sessionIdentity); + 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 ( diff --git a/test/fold.test.js b/test/fold.test.js index 08410860..68281d98 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -208,6 +208,87 @@ test("direct fold rejects a selected representative's inconsistent historical ob } }); +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 }); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index d11f612d..00faf7a3 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -319,6 +319,176 @@ test("a selected representative cannot grant its former selected observer to one } }); +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); @@ -463,7 +633,7 @@ test("OMP parent and subagent persist one ledger sighting and keep it in the chi assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); const childOnly = await foldForRun(h.ctx, memoryFile(), "h1", [], [child]); - assert.equal(childOnly.totals.gapSightings, 1, "the sampled child keeps its parent's ledger sighting"); + assert.equal(childOnly.totals.gapSightings, 2, "unresolved history and fresh native evidence remain separate sightings"); assert.equal(childOnly.gaps.length, 0); const independentEvidence = evidence(independent); @@ -472,7 +642,7 @@ test("OMP parent and subagent persist one ledger sighting and keep it in the chi assert.equal(withIndependent.gaps.length, 1); assert.equal(withIndependent.gaps[0].sessions, 2); }); -test("a selected OMP child collapses legacy parent and child ledger sightings", async () => { +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"; @@ -513,6 +683,7 @@ test("a selected OMP child collapses legacy parent and child ledger sightings", 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); @@ -522,11 +693,9 @@ test("a selected OMP child collapses legacy parent and child ledger sightings", assert.equal(summary.gaps.length, 0, "old parent and child keys still represent one observer"); assert.equal(summary.totals.droppedGapSingletons, 1); - assert.deepEqual(Object.keys(Object.values(h.state.readGapLedger().entries)[0].sessions), [parentIdentity]); - assert.deepEqual( - Object.values(h.state.readGapLedger().entries)[0].sessions[parentIdentity].sightingQuotes.sort(), - ["quote from pi-child", "quote from pi-parent"], - ); + 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 () => { @@ -650,12 +819,15 @@ test("native sightings union across recording, migration and consolidation with }).gaps.length, 0); recordGapObservations(ledger, [records[1]]); - const replaced = ledgerGapObservations(ledger, MEMORY_PATH).find( - (observation) => observation.sessionId === "observer-a", - ); - assert.equal(replaced.sourceSessionId, "child-web"); - assert.deepEqual(replaced.sightingIds.sort(), ["child-api", "child-web"]); - assert.deepEqual(replaced.sightingQuotes.sort(), ["quote from child-api", "quote from child-web"]); + 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 () => { @@ -760,7 +932,7 @@ test("an OMP parent and subagent sharing a root vote project in one run whatever 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 }); + 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 }; diff --git a/test/proposal.test.js b/test/proposal.test.js index cb3fb87b..39ab03da 100644 --- a/test/proposal.test.js +++ b/test/proposal.test.js @@ -15,7 +15,7 @@ import { SHRINK_MAX_EDITS, } from "../src/proposal.js"; import { foldEvidence, renderEvidenceForPrompt, renderEvidenceReport } from "../src/fold.js"; -import { ledgerGapObservations, recordGapObservations } from "../src/gap-ledger.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"; @@ -726,6 +726,110 @@ test("shared observer gap sightings do not widen native ownership of unrelated q 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 e50b63ee..addd1657 100644 --- a/test/remote-discovery.test.js +++ b/test/remote-discovery.test.js @@ -220,6 +220,157 @@ test("a copied OMP root and retained remote descendants count as one observer wi 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"]) { From 05f641d5a2e4b9eee7388d2b13ede45651161e4a Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 16:13:07 -0500 Subject: [PATCH 16/17] no-mistakes(document): Correct observer documentation and format changed sources --- README.md | 5 +- src/commands/propose.js | 18 ++++--- src/discovery/index.js | 15 +++--- src/fold.js | 22 ++++---- src/gap-ledger.js | 110 ++++++++++++++++++++++------------------ src/proposal.js | 2 +- test/nested.test.js | 1 - 7 files changed, 95 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 036e09cc..22e591bd 100644 --- a/README.md +++ b/README.md @@ -428,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 diff --git a/src/commands/propose.js b/src/commands/propose.js index 3d2acf9a..89231762 100644 --- a/src/commands/propose.js +++ b/src/commands/propose.js @@ -105,14 +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).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 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/discovery/index.js b/src/discovery/index.js index b60d5772..b520e6d7 100644 --- a/src/discovery/index.js +++ b/src/discovery/index.js @@ -42,9 +42,8 @@ export function getAdapter(harness) { * results are memoised in `.backpass/scan-cache.json` keyed by path + mtime + size. An * adapter may also export `cacheVersion` (bumped when its classification semantics * change) and `cacheDependency` (a fingerprint of other files a descriptor reads, such - * as OMP ancestor sessions); a mismatch in either reclassifies the entry. Re-scans are - * then O(new files) - which matters: codex alone had 10,317 rollouts on the machine this - * was designed against. + * as OMP ancestor sessions); a mismatch in either reclassifies the entry. 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. @@ -281,14 +280,14 @@ function dropCrossHostDuplicates(transcripts) { if ( (transcript.parentSessionId || (transcript.harness === "pi" && transcript.interactionSignals.source === "subagent")) && - (previous.parentSessionId || - (previous.harness === "pi" && previous.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); + if (!observerCorrespondences.has(retainedObserverKey)) + observerCorrespondences.set(retainedObserverKey, previous); } continue; } @@ -304,7 +303,9 @@ function dropCrossHostDuplicates(transcripts) { if ( !previous || (!observerCorrespondences.has(key) && - previous.host === (transcript.host || null) && !previous.parentSessionId && transcript.parentSessionId) + previous.host === (transcript.host || null) && + !previous.parentSessionId && + transcript.parentSessionId) ) { observers.set(key, transcript); } diff --git a/src/fold.js b/src/fold.js index 57e8e2f8..841fb9df 100644 --- a/src/fold.js +++ b/src/fold.js @@ -287,7 +287,10 @@ export function foldEvidence( ? gapClusters.map((cluster, index) => allDecided[index].failedTriggerSkill ? null - : gapOwnerOf(cluster.items.filter((item) => !item.projectCovered), route), + : gapOwnerOf( + cluster.items.filter((item) => !item.projectCovered), + route, + ), ) : null; const routedGaps = []; @@ -374,9 +377,7 @@ export function foldEvidence( summary.rootOwnedGaps = gapClusters .filter((cluster, index) => owners[index] === null && cluster.sessions.size >= minGapEvidence) .map((cluster) => - cluster.items.flatMap((item) => - item.sightingQuotes.map((quote) => ({ sessionId: item.sessionId, quote })), - ), + cluster.items.flatMap((item) => item.sightingQuotes.map((quote) => ({ sessionId: item.sessionId, quote }))), ); } summary.routedGaps = routedGaps; @@ -455,9 +456,7 @@ export function clusterGapObservations(observations, { checkProjectCoverage = fa quote: obs.quote, sightingQuotes: [ ...new Set( - [obs.quote, ...(obs.sightingQuotes || [])].filter( - (quote) => typeof quote === "string" && quote.length > 0, - ), + [obs.quote, ...(obs.sightingQuotes || [])].filter((quote) => typeof quote === "string" && quote.length > 0), ), ], recurrenceRisk: obs.recurrenceRisk, @@ -586,10 +585,6 @@ function gapOwnerOf(items, route) { return route.ownerOf(items.flatMap((item) => item.sightingIds)); } -/** - * 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 representativeGapItems(items, route) { const selected = items.slice(0, 6); if (!route || items.length <= 6) return selected; @@ -602,6 +597,11 @@ function representativeGapItems(items, route) { 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 0a5d30ef..96bdd61a 100644 --- a/src/gap-ledger.js +++ b/src/gap-ledger.js @@ -35,14 +35,15 @@ import { corroborationIdentityOf } from "./transcript.js"; * afterward (majority orchestration withholds a cluster from proposals; a mixed * cluster stays visible). A missing domain counts as project, so evidence from * before the field existed keeps its old behavior. - * - Sessions are keyed by corroboration identity (`corroborationIdentityOf`: the root - * session's canonical identity for an OMP subagent, the transcript's own otherwise), so - * re-analyzing or re-sampling the same source session, or a subagent of it, overwrites - * its observation and never adds a count. An older per-file identity key migrates to - * it; a legacy id key migrates only when the fold proved that id belongs to one - * evidence identity (`legacyIds`). Persisted observations only contribute when that - * identity belongs to the current selected sample, so sessions outside the window or - * cap cannot skew fold. + * - 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 @@ -213,18 +214,17 @@ function destinationAcceptsObservation(observation, sessionIdentity, transcript, 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); - }) + (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); + })) ); } @@ -276,9 +276,8 @@ function sightingQuotesOf(observation) { } /** - * Fold this run's evidence into the ledger. One observation per (gap, session); a - * session seen again replaces its own observation and keeps its first-seen timestamp. - * A legacy `transcript.id` key is that session's only when it is in `legacyIds`. + * 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[], legacyIds?: Set, transcripts?: object[] }} [options] */ @@ -326,14 +325,24 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { } let destinationIdentity = sessionIdentity; let sourceQualified = false; - if (!destinationAcceptsObservation( - entry.sessions[destinationIdentity], sessionIdentity, transcript, observersByNative, - )) { + if ( + !destinationAcceptsObservation( + entry.sessions[destinationIdentity], + sessionIdentity, + transcript, + observersByNative, + ) + ) { destinationIdentity = transcript.identity || transcript.id; } - if (!destinationAcceptsObservation( - entry.sessions[destinationIdentity], sessionIdentity, transcript, observersByNative, - )) { + if ( + !destinationAcceptsObservation( + entry.sessions[destinationIdentity], + sessionIdentity, + transcript, + observersByNative, + ) + ) { destinationIdentity = JSON.stringify([sessionIdentity, transcript.identity || transcript.id]); sourceQualified = true; } @@ -341,7 +350,9 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { entry, sessionIdentity, (sourceQualified ? [] : sessionIdentityAliases(transcript, sessionIdentity, legacyIds)).filter( - (identity) => identity !== destinationIdentity && !entry.sessions[identity]?.sourceSessionId && + (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); @@ -403,10 +414,8 @@ export function recordGapObservations(ledger, evidenceRecords, options = {}) { return recorded; } /** - * Re-key selected sessions in old ledgers when related transcript files now share an - * identity. A legacy `transcript.id` key migrates only when it is in `legacyIds`: the ids - * the fold proved belong to exactly one evidence identity, so an ambiguous id never moves - * another session's sighting onto a selected one. + * 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 = []; @@ -426,20 +435,26 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne 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; + 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); - }) + const observers = observersByNative.get(native); + return observers?.size === 1 && observers.has(sessionIdentity); + }) : resolvesTo(prior, sessionIdentity); }); for (const [identity, prior] of Object.entries(entry.sessions)) { @@ -448,14 +463,13 @@ export function normalizeGapLedgerSessions(ledger, transcripts, { legacyIds = ne prior.sourceSessionId === (transcript.identity || transcript.id) && resolvesTo(prior, sessionIdentity) && !aliases.includes(identity) - ) aliases.push(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]), - ]; + observation.sightingIds = [...new Set([...(observation.sightingIds || []), observation.sourceSessionId])]; delete observation.unattributedSightings; } if (!aliases.some((identity) => entry.sessions[identity])) continue; diff --git a/src/proposal.js b/src/proposal.js index 00742b80..5ac6b9ea 100644 --- a/src/proposal.js +++ b/src/proposal.js @@ -653,7 +653,7 @@ export function buildProposal(rawResult, context) { }); const owner = rootOwnsGap(sightings, routing.rootOwnedGaps ?? summary?.rootOwnedGaps) ? routing.rootPath - : (completeAttribution ? routing.ownerOf(sessions) : null) ?? routing.rootPath; + : ((completeAttribution ? routing.ownerOf(sessions) : null) ?? routing.rootPath); const here = routing.weight ?? routing.rootPath; if (owner !== here) { violations.push( diff --git a/test/nested.test.js b/test/nested.test.js index ba5bba9f..9b3bad8e 100644 --- a/test/nested.test.js +++ b/test/nested.test.js @@ -588,7 +588,6 @@ test("an unknown native observer beside an API child keeps both the fold and pro route: rootRoute, }); assert.equal(rootSummary.gaps[0].sessions, 2); - assert.equal(rootSummary.sourceSessions[observations[1].source], undefined); for (const weight of [null, API.path]) { const route = { ...rootRoute, weight, rootOwnedGaps: rootSummary.rootOwnedGaps }; const summary = foldEvidence([], { minGapEvidence: 2, gapObservations: observations, route }); From 188c543ff95f5df98dd67e7fdb3a3abae786b442 Mon Sep 17 00:00:00 2001 From: Charles Brooker Date: Tue, 6 Oct 2026 16:14:36 -0500 Subject: [PATCH 17/17] no-mistakes(lint): Fix regression fixture types and format affected tests --- test/fold.test.js | 192 +++++++---- test/gap-ledger.test.js | 592 ++++++++++++++++++++++------------ test/nested.test.js | 298 ++++++++++------- test/proposal.test.js | 108 ++++--- test/remote-discovery.test.js | 123 ++++--- 5 files changed, 851 insertions(+), 462 deletions(-) diff --git a/test/fold.test.js b/test/fold.test.js index 68281d98..c8593894 100644 --- a/test/fold.test.js +++ b/test/fold.test.js @@ -147,30 +147,49 @@ test("current records and legacy gap observations share one label per session", 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 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", + 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", + 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", + 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, + minGapEvidence: 2, + memoryFile, + route, gapObservations: [historical, independent, ...(matchingGap ? [fresh] : [])], }); assert.equal(summary.sourceObservers[historical.source], "L"); @@ -180,81 +199,112 @@ test("direct fold rejects a selected representative's inconsistent historical ob 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", + "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, + 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", + 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])); + 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 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", + 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", + 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", + 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"), - ], { + 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.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"])); + 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], + 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], + 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, + 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); @@ -263,27 +313,42 @@ test("direct fold resolves retained native aliases without admitting incompatibl 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 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", + 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", + 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, + 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, + gapObservations: [fresh, { ...fresh, sessionId: "P" }], + minGapEvidence: 2, }); assert.deepEqual(singleton.gaps, []); assert.equal(singleton.totals.droppedGapSingletons, 1); @@ -726,10 +791,7 @@ test("OMP subagents share corroboration while relevance remains per file", () => 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(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); diff --git a/test/gap-ledger.test.js b/test/gap-ledger.test.js index 00faf7a3..edfd93bb 100644 --- a/test/gap-ledger.test.js +++ b/test/gap-ledger.test.js @@ -127,7 +127,9 @@ test("selected native provenance migrates durable observers without a fresh matc const native = (id, observer, gaps, memoryHash) => { const evidence = record(id, gaps, { memoryHash }); evidence.transcript = { - ...evidence.transcript, harness: "pi", nativeId: id, + ...evidence.transcript, + harness: "pi", + nativeId: id, corroborationIdentity: observer, }; evidence.key = evidenceKey(evidence.transcript, memoryHash); @@ -141,15 +143,11 @@ test("selected native provenance migrates durable observers without a fresh matc 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, - ]); + 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", - ]); + 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); @@ -164,7 +162,10 @@ test("selected native provenance migrates durable observers without a fresh matc test("observer migration requires complete selected unambiguous native provenance", () => { const selected = (identity, observer) => ({ - id: `pi-${identity}`, identity, harness: "pi", corroborationIdentity: observer, + id: `pi-${identity}`, + identity, + harness: "pi", + corroborationIdentity: observer, }); const cases = [ { name: "convergent", transcripts: [selected("G", "R"), selected("H", "R")], move: true }, @@ -173,27 +174,41 @@ test("observer migration requires complete selected unambiguous native provenanc { 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, + 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, + 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", + 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) } }, - } }; + 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); @@ -215,8 +230,12 @@ test("observer migration requires complete selected unambiguous native provenanc 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", + 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"] }, @@ -228,18 +247,22 @@ test("foldForRun leaves ambiguous and incompletely selected old observers outsid 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", + 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 } }, - } }); + 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, - ]); + 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); @@ -251,7 +274,10 @@ test("a selected representative cannot grant its former selected observer to one const native = (id, observer, gaps, memoryHash) => { const evidence = record(id, gaps, { memoryHash }); evidence.transcript = { - ...evidence.transcript, harness: "pi", nativeId: id, corroborationIdentity: observer, + ...evidence.transcript, + harness: "pi", + nativeId: id, + corroborationIdentity: observer, }; evidence.key = evidenceKey(evidence.transcript, memoryHash); return evidence; @@ -260,10 +286,7 @@ test("a selected representative cannot grant its former selected observer to one 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"), - ]); + 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"); @@ -272,26 +295,33 @@ test("a selected representative cannot grant its former selected observer to one 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", - }]; + 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 }); + 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", - ]); + 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"); @@ -301,18 +331,29 @@ test("a selected representative cannot grant its former selected observer to one 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" }, + 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))); } @@ -328,19 +369,26 @@ test("an occupied incompatible observer preserves its native alias through norma }; 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") }); + 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)); + 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.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), @@ -354,8 +402,12 @@ test("an occupied incompatible observer preserves its native alias through norma 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", + 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")] }, @@ -364,18 +416,31 @@ test("unresolved occupied destinations do not absorb valid selected native alias ]) { 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", + 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", + 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) }, + }, + }, }; - 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); @@ -383,8 +448,10 @@ test("unresolved occupied destinations do not absorb valid selected native alias 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"])); + assert.deepEqual( + new Set(ledger.entries[entryId].sessions.H.sightingQuotes), + new Set(["valid H quote", "quote from H"]), + ); } }); @@ -395,16 +462,20 @@ test("selected native history corroborates separately from an incompatible occup const native = (id, observer, gaps, memoryHash) => { const evidence = record(id, gaps, { memoryHash }); evidence.transcript = { - ...evidence.transcript, harness: "pi", nativeId: id, corroborationIdentity: observer, + ...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") }); + 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); @@ -416,9 +487,16 @@ test("selected native history corroborates separately from an incompatible occup 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 }, - }); + 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); @@ -436,18 +514,30 @@ test("selected native history corroborates separately from an incompatible occup } 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" }, + 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]); @@ -466,18 +556,20 @@ test("fresh native evidence retains a deterministic sibling when its own observe return evidence; }; const ledger = { version: 1, entries: {} }; - recordGapObservations(ledger, [ - native("P", "P", [GAP], "h1"), native("G", "P", [GAP], "h1"), - ]); + 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"), - ]; + 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)); + 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"]); @@ -493,27 +585,40 @@ test("selected historical sightings remain admitted when their representative is 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", + 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 } }, - } }); + 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 }); + 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( + 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); }); @@ -522,20 +627,29 @@ test("fresh matching evidence cannot bypass rejected attributed alias migration" 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", + 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 } }, - } }); + 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({ - id: "H", identity: "H", harness: "pi", corroborationIdentity: destination, - }); + 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, []); @@ -550,10 +664,17 @@ test("fresh matching evidence cannot bypass rejected attributed alias migration" 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 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"]) }); @@ -633,7 +754,11 @@ test("OMP parent and subagent persist one ledger sighting and keep it in the chi 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.totals.gapSightings, + 2, + "unresolved history and fresh native evidence remain separate sightings", + ); assert.equal(childOnly.gaps.length, 0); const independentEvidence = evidence(independent); @@ -713,33 +838,43 @@ test("native aliases migrate while root-only observations keep unknown provenanc 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", - }])), + 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 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", - ]); + 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]; @@ -747,27 +882,38 @@ test("native aliases migrate while root-only observations keep unknown provenanc 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", - }])), + 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)); + 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", () => { @@ -776,9 +922,14 @@ test("native sightings union across recording, migration and consolidation with 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, + 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]), @@ -796,17 +947,22 @@ test("native sightings union across recording, migration and consolidation with 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)); + 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 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 }); @@ -814,9 +970,14 @@ test("native sightings union across recording, migration and consolidation with 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); + 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); @@ -832,24 +993,38 @@ test("native sightings union across recording, migration and consolidation with 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 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}`, - }])), + 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 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), }); @@ -864,14 +1039,19 @@ test("ordinary non-OMP selected legacy observations keep their native routing id 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", + 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])]]); + 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: {} }; @@ -879,33 +1059,45 @@ test("absorbed unknown sightings stay root-owned through merge and overwrite", ( 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", - } }, + 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] }], - }]); + 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, + 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); + assert.equal( + foldEvidence([], { + gapObservations: observations, + minGapEvidence: 1, + route: routingFor([api], attribution, MEMORY_PATH, api.path), + }).gaps.length, + 0, + ); } }); diff --git a/test/nested.test.js b/test/nested.test.js index 9b3bad8e..ed74962d 100644 --- a/test/nested.test.js +++ b/test/nested.test.js @@ -447,14 +447,16 @@ test("OMP child native ownership agrees across direct and ledger-only folds", as "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 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 = []; @@ -483,24 +485,24 @@ test("OMP child native ownership agrees across direct and ledger-only folds", as }; 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)); + 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"], - } }; + 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 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; @@ -522,35 +524,46 @@ test("OMP child native ownership agrees across direct and ledger-only folds", as 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]]); + 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); + 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, - }); + 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}`))); @@ -568,17 +581,26 @@ test("an unknown native observer beside an API child keeps both the fold and pro "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 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: "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", + 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); @@ -597,23 +619,34 @@ test("an unknown native observer beside an API child keeps both the fold and pro assert.equal(summary.routedGaps[0].owner, "AGENTS.md"); } const staged = stageAndMeasure({ - repo, memoryPath: weight ?? "AGENTS.md", + 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, - }); + 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); @@ -623,20 +656,30 @@ test("an unknown native observer beside an API child keeps both the fold and pro 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: [] }, - }); + 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); } }); @@ -666,13 +709,21 @@ test("same-root API and WEB quote variants keep the API sibling root-pinned", as }, })); 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 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, @@ -709,46 +760,51 @@ test("same-root API and WEB quote variants keep the API sibling root-pinned", as 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 }, + assert.ok( + root.rootOwnedGaps[0].some((item) => item.sessionId === "shared-observer" && item.quote === "shared-web hit it"), ); - const persistedApi = await foldForRun( - nested, staged.memoryFile, "changed-api-hash", [], nestedTranscripts, { route }, + 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, - }); + 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.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"); diff --git a/test/proposal.test.js b/test/proposal.test.js index 39ab03da..44dd6d87 100644 --- a/test/proposal.test.js +++ b/test/proposal.test.js @@ -626,14 +626,16 @@ test("native OMP quote labels still measure one proposal observer per root", () 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, - })), - })], + edits: [ + claim(["H1"], { + kind: "rewrite", + evidence: summary.instructions[0].quotes.map((quote) => ({ + polarity: "negative", + text: quote.text, + source: quote.source, + })), + }), + ], }, context: { summary }, }); @@ -642,9 +644,7 @@ test("native OMP quote labels still measure one proposal observer per root", () 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)), - ); + assert.ok(result.violations.some((violation) => /backed by 1 session\(s\); 2 are required/.test(violation))); } } }); @@ -656,7 +656,10 @@ test("shared observer gap sightings do not widen native ownership of unrelated q status: "ok", memoryPath: "AGENTS.md", transcript: { - id, identity: id, nativeId: id, harness: "pi", + id, + identity: id, + nativeId: id, + harness: "pi", startedAt: Date.parse("2026-08-02T00:00:00Z"), corroborationIdentity: observer, interaction: "non-interactive", @@ -682,31 +685,60 @@ test("shared observer gap sightings do not widen native ownership of unrelated q 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, + 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( + 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); + 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, + 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 }, - }); + 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); @@ -716,7 +748,9 @@ test("shared observer gap sightings do not widen native ownership of unrelated q 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, + polarity: "negative", + text: observation.quote, + source: observation.source, })); const rootGap = propose("AGENTS.md", sharedGap, gapEvidence); assert.deepEqual(rootGap.violations, []); @@ -765,10 +799,7 @@ test("unknown historical gaps preserve current native attribution for unrelated mergeGapEntries(ledger, [[knownId, unknownId]]); recordGapObservations(ledger, [a]); const observations = ledgerGapObservations(ledger, "AGENTS.md"); - assert.equal( - observations.find((observation) => observation.sessionId === "observer-a").unattributedSightings, - true, - ); + 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"]], @@ -791,7 +822,14 @@ test("unknown historical gaps preserve current native attribution for unrelated edit: (root) => writeIn(root, memoryPath, (text) => `${text}- ${instruction}\n`), }); return buildProposal( - { edits: [claim(staged.measured.changes.map((change) => change.id), { kind: "add", evidence })] }, + { + edits: [ + claim( + staged.measured.changes.map((change) => change.id), + { kind: "add", evidence }, + ), + ], + }, { repo, memoryFile: staged.memoryFile, @@ -818,9 +856,7 @@ test("unknown historical gaps preserve current native attribution for unrelated 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.ok(launderedGap.violations.some((violation) => /cross-cutting evidence belongs in AGENTS.md/.test(violation))); assert.equal( foldEvidence([a, b], { gapObservations: observations, diff --git a/test/remote-discovery.test.js b/test/remote-discovery.test.js index addd1657..8b72ed16 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, projectRun, sshCalls, tmpdir, withRemoteEnv, writeClaudeSession } from "./helpers/remote.js"; -import { disambiguateSourceLabels, gapSource, ledgerGapObservations, recordGapObservations } 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"; @@ -71,13 +84,15 @@ function observerRecords(transcripts) { 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", - }], + gaps: [ + { + proposedInstruction: "Read docs/db.md before writing queries.", + mistake: "re-derived the schema", + quote: "read the schema", + recurrenceRisk: "high", + domain: "project", + }, + ], })); } @@ -153,7 +168,11 @@ test("a copied OMP root and retained remote descendants count as one observer wi 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); + writeOmpSession( + path.join(remoteRoot, rootName, "Child", "Child.Grandchild.jsonl"), + "grandchild-native", + s.remoteClone, + ); const discover = () => withRemotePiEnv(s, () => discoverProject(s.repoRoot, { @@ -179,13 +198,15 @@ test("a copied OMP root and retained remote descendants count as one observer wi 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", - }], + 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); @@ -211,7 +232,9 @@ test("a copied OMP root and retained remote descendants count as one observer wi 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 }); + 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"); @@ -244,7 +267,10 @@ for (const remoteOnly of [false, true]) { 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"]]) { + 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); } @@ -327,7 +353,10 @@ for (const filtered of [false, true]) { return discovered; }); assert.equal(result.transcripts.length, 4); - assert.deepEqual(result.perHost.map((host) => host.duplicates), filtered ? [1, 0] : [2, 1]); + 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"); @@ -361,7 +390,10 @@ for (const filtered of [false, true]) { 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()); + 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"), @@ -389,7 +421,14 @@ for (const remoteOnly of [false, true]) { 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 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"); @@ -531,9 +570,7 @@ for (const remoteOnly of [false, true]) { 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; + const familyIdentity = missingFirstRoot ? child.identity : child.corroborationIdentity; assert.equal(grandchild.corroborationIdentity, familyIdentity); assert.equal(independent.corroborationIdentity, independent.identity); assert.notEqual(independent.identity, familyIdentity); @@ -630,26 +667,29 @@ test("filtered OMP roots still share one cross-host gap and harm observer", asyn 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 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 }); + 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, @@ -708,7 +748,10 @@ test("remote nested OMP automation is non-interactive without readable ancestor discovery: { hosts: ["mac-home"], harnesses: ["pi"], since: "all", includeOmp: true }, }), ); - for (const [childId, rootId] of [["child-native", "root-native"], ["grandchild-native", "other-root-native"]]) { + 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);