From 7a7f98c7d557647d7d12a53c0cc9296628a54f38 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 11:54:12 -0700 Subject: [PATCH 01/11] feat(session): verify compaction spine and record adopted handoffs A repaired summary spine changes bytes, so the evidence gate reads the next fold's dropped spine as uncovered and rejects every later fold. Score the spine against continuation facts, repair or abort, and record each adopted handoff for the archive to certify. --- src/session/compaction-archive.ts | 31 ++ src/session/compaction-verify.test.ts | 291 ++++++++++++ src/session/compaction-verify.ts | 437 ++++++++++++++++++ src/session/compactor.ts | 39 ++ src/session/summarizer.ts | 17 +- .../integration/compaction-atomicity.test.ts | 80 ++++ 6 files changed, 894 insertions(+), 1 deletion(-) create mode 100644 src/session/compaction-verify.test.ts create mode 100644 src/session/compaction-verify.ts diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index 4e34ce5cf..00100d0ec 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -912,11 +912,41 @@ function isSyntheticHandoffText(text: string): boolean { return text.startsWith(COMPACTED_PREFIX); } +/** + * Persist genuinely new user text in the proposed output (the new spine) + * as archive occurrences so a later fold can drop them. Output units already + * present verbatim in the input need no recording; the spine never passed + * through inbound admission. Best effort: callers treat adoption as certified. + */ +async function recordFreshHandoffOutput( + archive: CompactionArchive, + input: readonly ConversationTurn[], + output: readonly ConversationTurn[], +): Promise { + const fresh = uncoveredContentUnits(output, input); + for (const unit of fresh) { + if (unit.kind !== "text" || unit.role !== "user") continue; + const text = unit.text ?? ""; + if (text.length === 0) continue; + try { + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: text, + provenance: "compaction-handoff", + }); + } catch { + // Adoption stands; the next fold simply re-proves coverage another way. + } + } +} + /** * Refuse a destructive compact when the evidence archive cannot certify the * dropped prefix. Historical gap:true rows are not part of the expected set. * Synthetic handoff spines (and pre-format fat summaries) are adopted into * the archive as user_message so a later fold may change the live spine. + * After the fold certifies, the new spine is recorded so the next fold can + * drop it even when the summarizer does not echo it verbatim. */ export function wrapCompactorWithCompletenessGate( inner: Compactor, @@ -952,6 +982,7 @@ export function wrapCompactorWithCompletenessGate( if (certificate.status !== "complete") { return incompleteIdentity(inner, turns); } + await recordFreshHandoffOutput(archive, turns, proposed.output); return proposed; }, }; diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts new file mode 100644 index 000000000..70d1ea456 --- /dev/null +++ b/src/session/compaction-verify.test.ts @@ -0,0 +1,291 @@ +import { describe, test, expect } from "bun:test"; +import { createPruningCompactor } from "./compactor.js"; +import { condenseTurns } from "./summarizer.js"; +import { + extractContinuationFacts, + repairSummary, + verifyCompactionSummary, + verifyOrRepair, + VERIFY_REPAIR_HEADING, +} from "./compaction-verify.js"; +import type { + ConversationTurn, + ReactorState, + StrategyContext, +} from "@intx/types/runtime"; + +const mockStrategyCtx: StrategyContext = { + state: {} as ReactorState, + trigger: "test", +}; + +function textTurn( + role: ConversationTurn["role"], + text: string, + extra: Partial = {}, +): ConversationTurn { + return { + role, + content: [{ type: "text", text }], + timestamp: Date.now(), + ...extra, + }; +} + +function allText(turns: ConversationTurn[]): string { + return turns + .flatMap((t) => + t.content.filter((b) => b.type === "text").map((b) => b.text), + ) + .join("\n"); +} + +// A dropped region with a standing goal, an exact path, a verification +// command, and an unresolved failure. +function droppedTurns(): ConversationTurn[] { + return [ + textTurn("user", "Migrate the auth module to opaque tokens in src/auth.ts"), + textTurn("assistant", "Reading the handler first."), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 3, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + content: [{ type: "text", text: "handler source" }], + }, + ], + timestamp: 4, + }, + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c2", + name: "run_shell", + arguments: { command: "bun run test auth" }, + }, + ], + timestamp: 5, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c2", + isError: true, + content: [{ type: "text", text: "token refresh assertion failed" }], + }, + ], + timestamp: 6, + }, + textTurn("assistant", "Fix the token refresh assertion next."), + ]; +} + +describe("extractContinuationFacts", () => { + test("lifts goal, exact names, verification, and blockers", () => { + const facts = extractContinuationFacts(droppedTurns()); + expect(facts.goal).toContain("opaque tokens"); + expect(facts.exactNames).toContain("src/auth.ts"); + expect(facts.verification).toContain("bun run test auth"); + expect(facts.blockers.join("\n")).toContain("refresh assertion failed"); + expect(facts.nextAction).toContain("refresh assertion next"); + }); + + test("empty turns yield vacuous facts, never an abort", () => { + const facts = extractContinuationFacts([]); + const report = verifyCompactionSummary("anything", facts); + expect(report.supported).toBe(true); + }); +}); + +describe("verifyCompactionSummary", () => { + test("a faithful handoff is supported", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run test " + + "auth; the token refresh assertion failed. Fix the token refresh " + + "assertion next.", + facts, + ); + expect(report.supported).toBe(true); + expect(report.misses).toEqual([]); + }); + + test("a lossy handoff misses the goal and the exact name", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Work continues. Next: fix tests.", + facts, + ); + expect(report.supported).toBe(false); + const kinds = report.misses.map((m) => m.kind); + expect(kinds).toContain("goal"); + expect(kinds).toContain("exactName"); + }); + + test("denying failure while errors were dropped is a contradiction", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Auth migration done. No errors remain.", + facts, + ); + expect(report.misses.some((m) => m.kind === "contradiction")).toBe(true); + }); +}); + +describe("verifyOrRepair", () => { + test("repairs a lossy handoff instead of shipping it", () => { + const facts = extractContinuationFacts(droppedTurns()); + const outcome = verifyOrRepair( + "Work continues. Next: fix tests.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary).toContain(VERIFY_REPAIR_HEADING); + const rereport = verifyCompactionSummary(outcome.summary, facts); + expect(rereport.misses.some((m) => m.kind === "goal")).toBe(false); + expect(rereport.misses.some((m) => m.kind === "exactName")).toBe(false); + }); + + test("aborts a contradicting handoff instead of shipping a lie", () => { + const facts = extractContinuationFacts(droppedTurns()); + const outcome = verifyOrRepair( + "Auth migration done. No errors remain.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + }); + + test("repairSummary names only what the handoff missed", () => { + const facts = extractContinuationFacts(droppedTurns()); + const repaired = repairSummary( + "Migrating auth to opaque tokens in src/auth.ts.", + facts, + verifyCompactionSummary( + "Migrating auth to opaque tokens in src/auth.ts.", + facts, + ).misses, + ); + expect(repaired).toContain(VERIFY_REPAIR_HEADING); + }); +}); + +describe("pruning compactor verify pass", () => { + test("a lossy fold is repaired: the goal and exact path survive", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => "Work continues. Next: fix tests.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(allText(result.output)).toContain("opaque tokens"); + expect(allText(result.output)).toContain("auth.ts"); + expect(result.record.decisions).toMatchObject({ verifyRepaired: 1 }); + }); + + test("a contradicting fold aborts: prior context is kept", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => "Auth migration done. No errors remain.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("verify failed — keeping prior context"); + expect(result.record.decisions).toMatchObject({ verifyAborted: 1 }); + }); + + test("a faithful fold ships without repair markers", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run " + + "test auth; the token refresh assertion failed. Fix the token " + + "refresh assertion next.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(allText(result.output)).not.toContain(VERIFY_REPAIR_HEADING); + expect(result.record.decisions).not.toMatchObject({ verifyRepaired: 1 }); + }); +}); + +describe("continuation facts survive many folds", () => { + test("goal, exact path, and next action hold after five lossy folds", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + summarize: async () => "Work continues. Next: fix tests.", + }); + let turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + for (let fold = 0; fold < 5; fold++) { + const result = await compactor.apply(turns, mockStrategyCtx); + // No fold may abort the eval: the lossy stub is repaired, not denied. + expect(result.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + turns = [ + ...result.output, + textTurn("user", `follow-up ${fold}`), + textTurn("assistant", `progress note ${fold}`), + ]; + } + const text = allText(turns); + expect(text).toContain("opaque tokens"); + expect(text).toContain("auth.ts"); + expect(text).toContain("refresh assertion"); + }); +}); + +describe("condenseTurns keep-set", () => { + test("pins the standing goal ahead of the recency window", () => { + const turns: ConversationTurn[] = [ + textTurn("user", "Standing goal: migrate auth to opaque tokens"), + ...Array.from({ length: 8 }, (_, i) => + textTurn("user", `follow-up dump number ${i}`), + ), + ]; + const condensed = condenseTurns(turns); + expect(condensed).toContain("opaque tokens"); + expect(condensed).toContain("Goal (first user message)"); + }); +}); diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts new file mode 100644 index 000000000..4e54cc6d4 --- /dev/null +++ b/src/session/compaction-verify.ts @@ -0,0 +1,437 @@ +// Verify pass for compaction folds. +// +// After the compactor writes a summary handoff, this module scores the new +// spine against the continuation facts the dropped turns carried (goal, +// state, next action, constraints, verification, blockers, exact names). +// A fold that drops or contradicts those facts would leave the next agent +// without steam, so the pass repairs the handoff deterministically or aborts +// the fold. Fail closed: never ship a lying spine. + +import { type } from "arktype"; +import type { ConversationTurn } from "@intx/types/runtime"; + +export const ContinuationFacts = type({ + /** First user ask: the standing goal the folds must preserve. */ + goal: "string", + /** Constraint-like sentences lifted from user turns. */ + constraints: "string[]", + /** Last substantive assistant/user text in the dropped region. */ + nextAction: "string", + /** Last error text or assistant snippet: where the work stood. */ + state: "string", + /** Verification commands run (shell/test invocations). */ + verification: "string[]", + /** Errored tool-result texts: what is still broken. */ + blockers: "string[]", + /** Exact file paths and URLs the next agent will need verbatim. */ + exactNames: "string[]", +}); +export type ContinuationFacts = typeof ContinuationFacts.infer; + +export type VerifyMissKind = + | "goal" + | "constraint" + | "nextAction" + | "exactName" + | "blocker" + | "verification" + | "contradiction"; + +export interface VerifyMiss { + kind: VerifyMissKind; + detail: string; +} + +export interface VerifyReport { + supported: boolean; + misses: VerifyMiss[]; +} + +export interface VerifyOutcome { + summary: string; + repaired: boolean; + aborted: boolean; + misses: VerifyMiss[]; +} + +export const VERIFY_REPAIR_HEADING = "## Carry-forward (verify repair)"; + +// Words that carry no identifying signal. Kept small on purpose: the scorer +// compares content words, and every extra stopword is a false mismatch. +const STOPWORDS = new Set([ + "the", + "a", + "an", + "and", + "or", + "but", + "for", + "with", + "from", + "that", + "this", + "these", + "those", + "are", + "was", + "were", + "been", + "have", + "has", + "had", + "will", + "would", + "should", + "could", + "what", + "when", + "where", + "which", + "while", + "after", + "before", + "into", + "over", + "under", + "about", + "your", + "you", + "our", + "its", + "also", + "just", + "still", + "even", + "then", + "than", + "such", + "more", + "most", + "some", + "any", + "all", + "each", + "both", + "only", +]); + +function significantTokens(text: string, cap = 24): string[] { + const out: string[] = []; + for (const word of text.toLowerCase().split(/[^a-z0-9_./-]+/)) { + if (word.length < 4 || STOPWORDS.has(word)) continue; + if (!out.includes(word)) out.push(word); + if (out.length >= cap) break; + } + return out; +} + +// Half (rounded up) of the fact's content words must appear in the summary. +// Short facts need all of their words: one shared word proves nothing. +function tokensSupported(fact: string, summary: string): boolean { + const tokens = significantTokens(fact); + if (tokens.length === 0) return true; + const lowered = summary.toLowerCase(); + const hits = tokens.filter((t) => lowered.includes(t)).length; + const needed = + tokens.length <= 2 ? tokens.length : Math.ceil(tokens.length / 2); + return hits >= needed; +} + +function userTexts(turns: readonly ConversationTurn[]): string[] { + const out: string[] = []; + for (const turn of turns) { + if (turn.role !== "user") continue; + for (const block of turn.content) { + if (block.type === "text" && block.text.trim().length > 0) + out.push(block.text); + } + } + return out; +} + +function assistantTexts(turns: readonly ConversationTurn[]): string[] { + const out: string[] = []; + for (const turn of turns) { + if (turn.role !== "assistant") continue; + for (const block of turn.content) { + if (block.type === "text" && block.text.trim().length > 0) + out.push(block.text); + } + } + return out; +} + +// Sentences that read like standing instructions rather than chat. +const CONSTRAINT_MARKERS = + /\b(must|must not|never|always|only|do not|don't|required|ensure|make sure|keep|without|no emojis?)\b/i; + +function extractConstraints(texts: readonly string[]): string[] { + const out: string[] = []; + for (const text of texts) { + for (const sentence of text.split(/(?<=[.!?\n])\s+/)) { + const trimmed = sentence.trim(); + if ( + trimmed.length >= 12 && + CONSTRAINT_MARKERS.test(trimmed) && + !out.includes(trimmed) + ) + out.push(trimmed.slice(0, 300)); + } + } + return out.slice(0, 8); +} + +function toolCallArgs( + turns: readonly ConversationTurn[], +): { name: string; args: Record }[] { + const out: { name: string; args: Record }[] = []; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type !== "tool_call") continue; + let args: Record = {}; + if ( + block.arguments !== null && + typeof block.arguments === "object" && + !Array.isArray(block.arguments) + ) + args = block.arguments as Record; + out.push({ name: block.name, args }); + } + } + return out; +} + +function erroredResultTexts(turns: readonly ConversationTurn[]): string[] { + const out: string[] = []; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type !== "tool_result" || block.isError !== true) continue; + const text = block.content + .flatMap((c) => (c.type === "text" ? [c.text] : [])) + .join("") + .trim(); + if (text.length > 0 && !out.includes(text)) out.push(text.slice(0, 300)); + } + } + return out.slice(0, 6); +} + +function hostnameOf(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +/** + * Pull the continuation facts out of the turns a fold is about to drop. + * Deterministic and total: no fact means nothing to verify, never an abort. + */ +export function extractContinuationFacts( + turns: readonly ConversationTurn[], +): ContinuationFacts { + const users = userTexts(turns); + const assistants = assistantTexts(turns); + const calls = toolCallArgs(turns); + const blockers = erroredResultTexts(turns); + + const goal = users[0] ?? ""; + const nextAction = + assistants[assistants.length - 1] ?? users[users.length - 1] ?? ""; + const state = + blockers[blockers.length - 1] ?? + assistants[assistants.length - 1] ?? + users[users.length - 1] ?? + ""; + + const exactNames: string[] = []; + const verification: string[] = []; + for (const { name, args } of calls) { + const path = args["path"]; + if ( + typeof path === "string" && + path.length > 0 && + !exactNames.includes(path) + ) + exactNames.push(path); + const url = args["url"]; + if (typeof url === "string" && url.length > 0 && !exactNames.includes(url)) + exactNames.push(url); + if (name === "run_shell") { + const command = args["command"]; + if ( + typeof command === "string" && + command.length > 0 && + !verification.includes(command) + ) + verification.push(command.slice(0, 200)); + } + } + for (const text of [...users, ...assistants]) { + for (const match of text.match(/https?:\/\/[^\s)]+/g) ?? []) { + if (!exactNames.includes(match)) exactNames.push(match); + } + } + + // Arktype at the boundary: the extractor's shape is the verifier's input + // contract, so a malformed fact fails here instead of scoring nonsense. + const parsed = ContinuationFacts({ + goal: goal.slice(0, 500), + constraints: extractConstraints(users), + nextAction: nextAction.slice(0, 300), + state: state.slice(0, 300), + verification: verification.slice(0, 6), + blockers, + exactNames: exactNames.slice(0, 20), + }); + if (parsed instanceof type.errors) throw new Error("invalid facts"); + return parsed; +} + +// Basename for paths (a summary that moves `src/auth.ts` to "auth.ts" still +// names it); hostname for URLs (query strings get reworded freely). +function exactNameSupported(name: string, summary: string): boolean { + const lowered = summary.toLowerCase(); + if (name.startsWith("http")) { + const host = hostnameOf(name); + if (host !== undefined && lowered.includes(host.toLowerCase())) return true; + return lowered.includes(name.toLowerCase()); + } + const base = name.split("/").pop() ?? name; + return base.length > 0 && lowered.includes(base.toLowerCase()); +} + +// Claims that deny failure while the dropped turns record it. Narrow on +// purpose: only an explicit denial contradicts, never a progress report. +const DENIES_FAILURE = + /\bno\s+(errors?|failures?|blockers?|issues?)\b|\bnothing\s+(pending|failing|left|outstanding)\b/i; + +/** + * Score a candidate handoff against the dropped turns' continuation facts. + * Missing or contradicted critical facts fail the fold. + */ +export function verifyCompactionSummary( + summary: string, + facts: ContinuationFacts, +): VerifyReport { + const misses: VerifyMiss[] = []; + + if (facts.goal.trim().length > 0 && !tokensSupported(facts.goal, summary)) { + misses.push({ kind: "goal", detail: facts.goal.slice(0, 200) }); + } + for (const constraint of facts.constraints) { + if (!tokensSupported(constraint, summary)) { + misses.push({ kind: "constraint", detail: constraint.slice(0, 200) }); + } + } + if ( + facts.nextAction.trim().length > 0 && + !tokensSupported(facts.nextAction, summary) + ) { + misses.push({ kind: "nextAction", detail: facts.nextAction.slice(0, 200) }); + } + for (const name of facts.exactNames) { + if (!exactNameSupported(name, summary)) { + misses.push({ kind: "exactName", detail: name }); + } + } + for (const blocker of facts.blockers) { + if (!tokensSupported(blocker, summary)) { + misses.push({ kind: "blocker", detail: blocker.slice(0, 200) }); + } + } + for (const command of facts.verification) { + if (!tokensSupported(command, summary)) { + misses.push({ kind: "verification", detail: command.slice(0, 200) }); + } + } + if (facts.blockers.length > 0 && DENIES_FAILURE.test(summary)) { + misses.push({ + kind: "contradiction", + detail: "summary denies failure while dropped turns record errors", + }); + } + + return { supported: misses.length === 0, misses }; +} + +/** + * Deterministic rewrite: append the missing facts verbatim under a repair + * heading so the next agent resumes with names and wording intact. + */ +export function repairSummary( + summary: string, + facts: ContinuationFacts, + misses: readonly VerifyMiss[], +): string { + const kinds = new Set(misses.map((m) => m.kind)); + const lines: string[] = [VERIFY_REPAIR_HEADING]; + const goalMiss = misses.find((m) => m.kind === "goal"); + if (goalMiss !== undefined) lines.push(`Goal: ${facts.goal}`); + const nextMiss = misses.find((m) => m.kind === "nextAction"); + if (nextMiss !== undefined) lines.push(`Next: ${facts.nextAction}`); + // The state line carries where the work stood when it is neither the next + // action nor an already-listed blocker. + const stateCovered = + facts.state.trim().length === 0 || + facts.state === facts.nextAction || + facts.blockers.includes(facts.state); + if ((kinds.has("blocker") || kinds.has("nextAction")) && !stateCovered) + lines.push(`State: ${facts.state}`); + if (kinds.has("constraint")) { + lines.push( + `Constraints:\n${facts.constraints.map((c) => `- ${c}`).join("\n")}`, + ); + } + if (kinds.has("blocker")) { + lines.push( + `Open blockers:\n${facts.blockers.map((b) => `- ${b}`).join("\n")}`, + ); + } + if (kinds.has("exactName")) { + const missing = misses + .filter((m) => m.kind === "exactName") + .map((m) => m.detail); + lines.push(`Exact references: ${missing.join(", ")}`); + } + if (kinds.has("verification")) { + lines.push(`Ran: ${facts.verification.join("; ")}`); + } + return `${summary.trimEnd()}\n\n${lines.join("\n")}`; +} + +/** + * Verify a candidate handoff, repairing once or aborting the fold. + * A contradiction aborts outright: an appended correction cannot retract the + * handoff's false denial. A truncating repair that still drops the goal + * aborts too — the fold ships the goal or it does not ship. + */ +export function verifyOrRepair( + summary: string, + facts: ContinuationFacts, + maxChars: number, +): VerifyOutcome { + const first = verifyCompactionSummary(summary, facts); + if (first.supported) + return { summary, repaired: false, aborted: false, misses: [] }; + if (first.misses.some((m) => m.kind === "contradiction")) + return { summary, repaired: false, aborted: true, misses: first.misses }; + + const repairedFull = repairSummary(summary, facts, first.misses); + const repaired = + repairedFull.length > maxChars + ? repairedFull.slice(0, maxChars) + : repairedFull; + const second = verifyCompactionSummary(repaired, facts); + if ( + second.misses.some((m) => m.kind === "contradiction" || m.kind === "goal") + ) + return { summary, repaired: false, aborted: true, misses: first.misses }; + return { + summary: repaired, + repaired: true, + aborted: false, + misses: first.misses, + }; +} diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 780c0562c..dd3cd50b1 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -22,6 +22,10 @@ import type { import { ageImageBlocks } from "./attachment-store.js"; import { buildHandoffFold, COMPACTED_PREFIX } from "./compaction-handoff.js"; import type { SummaryContext } from "./summarizer.js"; +import { + extractContinuationFacts, + verifyOrRepair, +} from "./compaction-verify.js"; import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS, @@ -1066,6 +1070,40 @@ export function createPruningCompactor( }; } + // Verify pass: the handoff must still carry the dropped turns' + // continuation facts (goal, next action, exact names, blockers). A + // lossy summary is repaired deterministically; a contradicting one + // aborts the fold so the next agent keeps the true context instead. + const verified = verifyOrRepair( + summary, + extractContinuationFacts(summarizedTurns), + cfg.summaryMaxChars, + ); + if (verified.aborted) { + return { + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { keepRecentTurns: cfg.keepRecentTurns }, + reason: "verify failed — keeping prior context", + decisions: { + verifyAborted: 1, + verifyMissing: verified.misses.map((m) => m.kind), + agedImageCount: aged.agedImageCount, + }, + }, + }; + } + summary = verified.summary; + const verifyDecisions = + verified.repaired && verified.misses.length > 0 + ? { + verifyRepaired: 1, + verifyMissing: verified.misses.map((m) => m.kind), + } + : {}; + // A user-role turn survives every adapter unchanged. A system-role turn // does not: the Anthropic builder drops mid-conversation system turns // whenever a system-prompt override is set, and the Grok builder emits @@ -1139,6 +1177,7 @@ export function createPruningCompactor( agedImageCount: aged.agedImageCount, supersededReadCount: supersededReads.size, repeatedErrorCount: repeatedErrors.size, + ...verifyDecisions, }, }, blobs: [...aged.blobs, handoff.blob], diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 378686131..7b4e5c84f 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -171,6 +171,21 @@ const SYSTEM_INSTRUCTION = [ "turns can retrieve the evidence. Do not invent archive contents.", ].join("\n"); +// The user-message window is recency-bounded, which starves the standing +// goal once the session runs long: the summary call would only see the last +// dumps. Pin the first user message — the initiating ask — ahead of the +// recent window so the goal survives no matter how many turns pile up. +const CONDENSED_USER_WINDOW = 6; + +function withPinnedGoal(userMessages: string[]): string[] { + const recent = userMessages.slice(-CONDENSED_USER_WINDOW); + if (userMessages.length <= CONDENSED_USER_WINDOW) return recent; + const goal = userMessages[0]; + if (goal !== undefined && !recent.includes(goal)) + return [`Goal (first user message):\n${goal}`, ...recent]; + return recent; +} + // Pull a compact, model-readable excerpt out of the turns being dropped: // recent user asks, assistant reasoning snippets, tool calls and the files // they touched. Bounded so the summary call itself stays cheap. @@ -221,7 +236,7 @@ export function condenseTurns(turns: ConversationTurn[]): string { .join("\n")}` : null, userMessages.length > 0 - ? `User messages (most recent last):\n${userMessages.slice(-6).join("\n---\n")}` + ? `User messages (most recent last):\n${withPinnedGoal(userMessages).join("\n---\n")}` : null, assistantSnippets.length > 0 ? `Assistant notes (excerpts):\n${assistantSnippets.slice(-8).join("\n---\n")}` diff --git a/tests/integration/compaction-atomicity.test.ts b/tests/integration/compaction-atomicity.test.ts index ee606ab53..daac6642a 100644 --- a/tests/integration/compaction-atomicity.test.ts +++ b/tests/integration/compaction-atomicity.test.ts @@ -130,6 +130,86 @@ describe("compaction atomicity", () => { expect(result.record.reason).toBe("incomplete-evidence-archive"); }); + test("adopted handoff is recorded so the next fold can drop the spine", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "primary", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const hit = blobs.get(key); + if (hit === undefined) throw new Error(`missing ${key}`); + return hit; + }, + }); + const foldingCompactor = ( + spine: string, + keep: ConversationTurn[], + ): Compactor => ({ + name: "pruning-compactor", + version: "1", + async apply(_turns) { + return { + output: [turn(spine), ...keep], + record: { + strategy: "pruning-compactor", + version: "1", + parameters: {}, + reason: "compact", + decisions: {}, + }, + }; + }, + }); + const history1 = [turn("fact-a"), turn("fact-b")]; + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-a", + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-b", + }); + + const first = wrapCompactorWithCompletenessGate( + foldingCompactor("[Compacted prior context] goal-line-1", [ + turn("fact-b"), + ]), + archive, + ); + const adopted1 = await first.apply(history1, ctx); + expect(adopted1.record.reason).toBe("compact"); + + // The new spine never passes through inbound admission, so adoption must + // leave it in the archive — otherwise the next fold rejects it as + // uncovered (a repaired spine is never echoed verbatim). + const handoffs = (await archive.listOccurrences()).filter( + (occurrence) => occurrence.provenance === "compaction-handoff", + ); + expect(handoffs).toHaveLength(1); + + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-c", + }); + const history2 = [...adopted1.output, turn("fact-c")]; + const second = wrapCompactorWithCompletenessGate( + foldingCompactor("[Compacted prior context] goal-line-2", [ + turn("fact-c"), + ]), + archive, + ); + const adopted2 = await second.apply(history2, ctx); + expect(adopted2.record.reason).toBe("compact"); + expect(texts(adopted2.output)).toEqual([ + "[Compacted prior context] goal-line-2", + "fact-c", + ]); + }); + test("primary complete rewrite publishes turns and evidence together", async () => { const dir = tempDir(); const store = await createOptimizedContextStore(dir); From 9b70f9b08590a7351b9630c5b04ff7b90952f4c0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 16:35:33 -0700 Subject: [PATCH 02/11] fix(session): abort residual repair misses and bound scorer tokens A cap that slices the repair tail used to ship a spine that still missed exact names, and the scorer treated auth as present inside authored. Abort any residual after the cap, and match tokens on word boundaries. --- src/context-compactor.test.ts | 5 ++-- src/session/compaction-verify.test.ts | 33 +++++++++++++++++++++++++ src/session/compaction-verify.ts | 35 +++++++++++++++++++++------ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 757656369..593a878ac 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -1243,11 +1243,12 @@ describe("buildTurnSummary via createPruningCompactor", () => { const turns: ConversationTurn[] = [ makeTurn({ role: "user", - content: [{ type: "text", text: "a".repeat(500) }], + // Short tokens so the verify pass is vacuous; length still overflows. + content: [{ type: "text", text: "yes ".repeat(200) }], }), makeTurn({ role: "assistant", - content: [{ type: "text", text: "b".repeat(500) }], + content: [{ type: "text", text: "ok ".repeat(200) }], }), makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), ]; diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 70d1ea456..fd228330b 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -139,6 +139,16 @@ describe("verifyCompactionSummary", () => { expect(kinds).toContain("exactName"); }); + test("auth as a goal token does not match authored", () => { + const facts = extractContinuationFacts([textTurn("user", "Fix auth now")]); + const report = verifyCompactionSummary( + "The authored notes: next step module plan is set.", + facts, + ); + expect(report.supported).toBe(false); + expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + }); + test("denying failure while errors were dropped is a contradiction", () => { const facts = extractContinuationFacts(droppedTurns()); const report = verifyCompactionSummary( @@ -176,6 +186,29 @@ describe("verifyOrRepair", () => { expect(outcome.repaired).toBe(false); }); + test("truncating repair that still misses exactName aborts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/very-long-unique-path/exact-file.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 90); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + const shipped = verifyCompactionSummary(outcome.summary, facts); + expect(shipped.misses.some((m) => m.kind === "exactName")).toBe(true); + }); + test("repairSummary names only what the handoff missed", () => { const facts = extractContinuationFacts(droppedTurns()); const repaired = repairSummary( diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 4e54cc6d4..7e091351f 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -127,11 +127,34 @@ function significantTokens(text: string, cap = 24): string[] { // Half (rounded up) of the fact's content words must appear in the summary. // Short facts need all of their words: one shared word proves nothing. +// Match on token boundaries so "auth" does not score against "authored". +function isTokenChar(ch: string | undefined): boolean { + if (ch === undefined) return false; + const code = ch.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || (code >= 97 && code <= 122) || code === 95 + ); +} + +function tokenAppears(token: string, lowered: string): boolean { + let from = 0; + for (;;) { + const i = lowered.indexOf(token, from); + if (i < 0) return false; + if ( + !isTokenChar(i === 0 ? undefined : lowered[i - 1]) && + !isTokenChar(lowered[i + token.length]) + ) + return true; + from = i + 1; + } +} + function tokensSupported(fact: string, summary: string): boolean { const tokens = significantTokens(fact); if (tokens.length === 0) return true; const lowered = summary.toLowerCase(); - const hits = tokens.filter((t) => lowered.includes(t)).length; + const hits = tokens.filter((t) => tokenAppears(t, lowered)).length; const needed = tokens.length <= 2 ? tokens.length : Math.ceil(tokens.length / 2); return hits >= needed; @@ -404,8 +427,8 @@ export function repairSummary( /** * Verify a candidate handoff, repairing once or aborting the fold. * A contradiction aborts outright: an appended correction cannot retract the - * handoff's false denial. A truncating repair that still drops the goal - * aborts too — the fold ships the goal or it does not ship. + * handoff's false denial. A truncating repair that still misses any fact + * aborts too — never ship a lying spine. */ export function verifyOrRepair( summary: string, @@ -424,10 +447,8 @@ export function verifyOrRepair( ? repairedFull.slice(0, maxChars) : repairedFull; const second = verifyCompactionSummary(repaired, facts); - if ( - second.misses.some((m) => m.kind === "contradiction" || m.kind === "goal") - ) - return { summary, repaired: false, aborted: true, misses: first.misses }; + if (second.misses.length > 0) + return { summary, repaired: false, aborted: true, misses: second.misses }; return { summary: repaired, repaired: true, From 3bb7b204792646cf402616c059c98987f9d2d88a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 16:54:52 -0700 Subject: [PATCH 03/11] fix(session): order compaction repair lines critical-first A tight cap used to slice exact names off the repair tail. Put goal, next action, and exact names first so truncation keeps the facts the next agent needs, and restore real verify coverage on the truncation path. --- src/context-compactor.test.ts | 44 ++++++--- src/session/compaction-verify.test.ts | 136 ++++++++++++++++++++++++++ src/session/compaction-verify.ts | 37 +++---- 3 files changed, 179 insertions(+), 38 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 593a878ac..00ca2486b 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -7,6 +7,7 @@ import { formatPlan, classifyTaskBoundary, buildLLMTurnSummary, + buildTurnSummary, COMPACTED_PREFIX, COMPACT_SPACER_TEXT, LEGACY_COMPACT_SPACER_TEXT, @@ -1234,7 +1235,26 @@ describe("buildTurnSummary via createPruningCompactor", () => { expect(file).toContain("Total tool calls: 1"); }); - test("truncates summary when it exceeds maxChars", async () => { + test("buildTurnSummary truncates with ellipsis when over maxChars", () => { + const maxChars = 20; + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }], + }), + makeTurn({ + role: "assistant", + content: [ + { type: "text", text: "patch the refresh handler ".repeat(40) }, + ], + }), + ]; + const summary = buildTurnSummary(turns, maxChars); + expect(summary.endsWith("...")).toBe(true); + expect(summary.length).toBe(maxChars); + }); + + test("a truncated lying spine aborts instead of shipping", async () => { const maxChars = 20; const compactor = createPruningCompactor({ keepRecentTurns: 1, @@ -1243,28 +1263,20 @@ describe("buildTurnSummary via createPruningCompactor", () => { const turns: ConversationTurn[] = [ makeTurn({ role: "user", - // Short tokens so the verify pass is vacuous; length still overflows. - content: [{ type: "text", text: "yes ".repeat(200) }], + content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }], }), makeTurn({ role: "assistant", - content: [{ type: "text", text: "ok ".repeat(200) }], + content: [ + { type: "text", text: "patch the refresh handler ".repeat(40) }, + ], }), makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), ]; const result = await compactor.apply(turns, mockStrategyCtx); - // CL-8744: the deterministic narrative lives in the fat handoff file's - // Summary section; the live output carries only the thin spine. - const file = new TextDecoder().decode( - defined(defined(result.blobs)[0]).bytes, - ); - const narrative = defined( - file.split("## Summary (this fold — may paraphrase)\n")[1], - ).split("## Exact facts")[0]; - // The embedded buildTurnSummary output ends with "..." when truncated... - expect(narrative).toContain("..."); - // ...and the truncated narrative stays within maxChars + 3 ("..." suffix). - expect(defined(narrative?.trim()).length).toBeLessThanOrEqual(maxChars + 3); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("verify failed — keeping prior context"); + expect(result.record.decisions).toMatchObject({ verifyAborted: 1 }); }); }); diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index fd228330b..88b820424 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -1,6 +1,13 @@ import { describe, test, expect } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { createPruningCompactor } from "./compactor.js"; import { condenseTurns } from "./summarizer.js"; +import { + createCompactionArchive, + wrapCompactorWithCompletenessGate, +} from "./compaction-archive.js"; import { extractContinuationFacts, repairSummary, @@ -221,6 +228,37 @@ describe("verifyOrRepair", () => { ); expect(repaired).toContain(VERIFY_REPAIR_HEADING); }); + + test("repair lines are goal, next, exact names, then the rest", () => { + const facts = extractContinuationFacts(droppedTurns()); + const misses = verifyCompactionSummary("Work continues.", facts).misses; + const repaired = repairSummary("Work continues.", facts, misses); + const goalAt = repaired.indexOf("Goal:"); + const nextAt = repaired.indexOf("Next:"); + const exactAt = repaired.indexOf("Exact references:"); + const blockersAt = repaired.indexOf("Open blockers:"); + const ranAt = repaired.indexOf("Ran:"); + expect(goalAt).toBeGreaterThan(-1); + expect(nextAt).toBeGreaterThan(goalAt); + expect(exactAt).toBeGreaterThan(nextAt); + expect(blockersAt).toBeGreaterThan(exactAt); + expect(ranAt).toBeGreaterThan(blockersAt); + }); + + test("a tight cap keeps goal, next, and names before aborting on the tail", () => { + const facts = extractContinuationFacts(droppedTurns()); + const misses = verifyCompactionSummary("Work continues.", facts).misses; + const full = repairSummary("Work continues.", facts, misses); + const cap = full.indexOf("Open blockers:"); + expect(cap).toBeGreaterThan(0); + const outcome = verifyOrRepair("Work continues.", facts, cap); + expect(outcome.aborted).toBe(true); + const kinds = outcome.misses.map((m) => m.kind); + expect(kinds).not.toContain("goal"); + expect(kinds).not.toContain("nextAction"); + expect(kinds).not.toContain("exactName"); + expect(kinds).toContain("verification"); + }); }); describe("pruning compactor verify pass", () => { @@ -309,6 +347,104 @@ describe("continuation facts survive many folds", () => { }); }); +describe("completeness gate plus verify repair", () => { + function memoryArchive() { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "compaction-verify-gate-"), + ); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-verify-gate", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + return archive; + } + + async function archiveTurns( + archive: ReturnType, + turns: readonly ConversationTurn[], + ): Promise { + for (const turn of turns) { + for (const block of turn.content) { + if (block.type === "text" && block.text.length > 0) { + await archive.recordAuthorizedPayload({ + kind: turn.role === "assistant" ? "assistant_text" : "user_message", + payload: block.text, + }); + } else if (block.type === "tool_call") { + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: block.name, arguments: block.arguments }, + callId: block.id, + }); + } else if (block.type === "tool_result") { + const text = block.content + .flatMap((c) => (c.type === "text" ? [c.text] : [])) + .join(""); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: text, + callId: block.callId, + }); + } + } + } + } + + test("two lossy folds through the gate keep facts via adopted handoffs", async () => { + const archive = memoryArchive(); + const inner = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + summarize: async () => "Work continues. Next: fix tests.", + }); + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + let turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + await archiveTurns(archive, turns); + + const first = await wrapped.apply(turns, mockStrategyCtx); + expect(first.record.reason).not.toBe("incomplete-evidence-archive"); + expect(first.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + expect(first.record.decisions).toMatchObject({ verifyRepaired: 1 }); + const handoffs = (await archive.listOccurrences()).filter( + (occurrence) => occurrence.provenance === "compaction-handoff", + ); + expect(handoffs.length).toBeGreaterThan(0); + expect(allText(first.output)).toContain("opaque tokens"); + expect(allText(first.output)).toContain("auth.ts"); + + const followUp = [ + textTurn("user", "follow-up after first fold"), + textTurn("assistant", "progress note after first fold"), + ]; + await archiveTurns(archive, followUp); + turns = [...first.output, ...followUp]; + + const second = await wrapped.apply(turns, mockStrategyCtx); + expect(second.record.reason).not.toBe("incomplete-evidence-archive"); + expect(second.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + expect(allText(second.output)).toContain("opaque tokens"); + expect(allText(second.output)).toContain("auth.ts"); + expect(allText(second.output)).toContain("refresh assertion"); + }); +}); + describe("condenseTurns keep-set", () => { test("pins the standing goal ahead of the recency window", () => { const turns: ConversationTurn[] = [ diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 7e091351f..dbd66e000 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -160,22 +160,13 @@ function tokensSupported(fact: string, summary: string): boolean { return hits >= needed; } -function userTexts(turns: readonly ConversationTurn[]): string[] { - const out: string[] = []; - for (const turn of turns) { - if (turn.role !== "user") continue; - for (const block of turn.content) { - if (block.type === "text" && block.text.trim().length > 0) - out.push(block.text); - } - } - return out; -} - -function assistantTexts(turns: readonly ConversationTurn[]): string[] { +function textsForRole( + turns: readonly ConversationTurn[], + role: "user" | "assistant", +): string[] { const out: string[] = []; for (const turn of turns) { - if (turn.role !== "assistant") continue; + if (turn.role !== role) continue; for (const block of turn.content) { if (block.type === "text" && block.text.trim().length > 0) out.push(block.text); @@ -254,8 +245,8 @@ function hostnameOf(url: string): string | undefined { export function extractContinuationFacts( turns: readonly ConversationTurn[], ): ContinuationFacts { - const users = userTexts(turns); - const assistants = assistantTexts(turns); + const users = textsForRole(turns, "user"); + const assistants = textsForRole(turns, "assistant"); const calls = toolCallArgs(turns); const blockers = erroredResultTexts(turns); @@ -394,6 +385,14 @@ export function repairSummary( if (goalMiss !== undefined) lines.push(`Goal: ${facts.goal}`); const nextMiss = misses.find((m) => m.kind === "nextAction"); if (nextMiss !== undefined) lines.push(`Next: ${facts.nextAction}`); + // Critical-first: goal, next action, exact names, then the rest. A cap + // that slices the tail still prefers the facts the next agent needs. + if (kinds.has("exactName")) { + const missing = misses + .filter((m) => m.kind === "exactName") + .map((m) => m.detail); + lines.push(`Exact references: ${missing.join(", ")}`); + } // The state line carries where the work stood when it is neither the next // action nor an already-listed blocker. const stateCovered = @@ -412,12 +411,6 @@ export function repairSummary( `Open blockers:\n${facts.blockers.map((b) => `- ${b}`).join("\n")}`, ); } - if (kinds.has("exactName")) { - const missing = misses - .filter((m) => m.kind === "exactName") - .map((m) => m.detail); - lines.push(`Exact references: ${missing.join(", ")}`); - } if (kinds.has("verification")) { lines.push(`Ran: ${facts.verification.join("; ")}`); } From f43e35a7c86a20fb05bd1f268b61cbec47829b2b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 16:58:41 -0700 Subject: [PATCH 04/11] test(session): pin residual verify misses and token boundaries Re-scoring the aborted original summary was tautological. Assert the outcome misses, add a roomy-cap repair control, abort on a constraint-only residual, and reject author/preauth as auth hits. --- src/session/compaction-verify.test.ts | 66 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 88b820424..ce5fb87a9 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -146,14 +146,17 @@ describe("verifyCompactionSummary", () => { expect(kinds).toContain("exactName"); }); - test("auth as a goal token does not match authored", () => { + test("auth as a goal token does not match authored, author, or preauth", () => { const facts = extractContinuationFacts([textTurn("user", "Fix auth now")]); - const report = verifyCompactionSummary( + for (const summary of [ "The authored notes: next step module plan is set.", - facts, - ); - expect(report.supported).toBe(false); - expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + "The author notes: next step module plan is set.", + "The preauth notes: next step module plan is set.", + ]) { + const report = verifyCompactionSummary(summary, facts); + expect(report.supported).toBe(false); + expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + } }); test("denying failure while errors were dropped is a contradiction", () => { @@ -212,8 +215,55 @@ describe("verifyOrRepair", () => { const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 90); expect(outcome.aborted).toBe(true); expect(outcome.repaired).toBe(false); - const shipped = verifyCompactionSummary(outcome.summary, facts); - expect(shipped.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(outcome.misses.some((m) => m.kind === "exactName")).toBe(true); + }); + + test("a roomy cap repairs and keeps the exact-name basename", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/very-long-unique-path/exact-file.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const outcome = verifyOrRepair( + "Fix auth now. Work continues.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary).toContain("exact-file.ts"); + }); + + test("a constraint-only residual after a sliced repair aborts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + textTurn("assistant", "Working on auth now."), + textTurn("user", "Do not commit generated artifacts ever."), + textTurn("assistant", "Working on auth now."), + ]); + expect( + facts.constraints.some((c) => c.includes("generated artifacts")), + ).toBe(true); + const summary = "Fix auth now. Working on auth now."; + const misses = verifyCompactionSummary(summary, facts).misses; + expect(misses.map((m) => m.kind)).toEqual(["constraint"]); + const full = repairSummary(summary, facts, misses); + const cap = full.indexOf("Constraints:"); + expect(cap).toBeGreaterThan(0); + const outcome = verifyOrRepair(summary, facts, cap); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + expect(outcome.misses.some((m) => m.kind === "constraint")).toBe(true); }); test("repairSummary names only what the handoff missed", () => { From 4da192780510aa215cce5ae02a9096d0c084ed5a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:18:05 -0700 Subject: [PATCH 05/11] fix(session): bound exactName matches and repair only misses Substring includes let oauth.ts cover auth.ts. Match names on token boundaries, emit only missed constraint/blocker/verification lines, and assert a real verify signal after folds. --- src/session/compaction-verify.test.ts | 134 ++++++++++++++++++++++---- src/session/compaction-verify.ts | 29 ++++-- 2 files changed, 136 insertions(+), 27 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index ce5fb87a9..234d4754c 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -167,6 +167,82 @@ describe("verifyCompactionSummary", () => { ); expect(report.misses.some((m) => m.kind === "contradiction")).toBe(true); }); + + test("oauth.ts does not cover src/auth.ts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read oauth.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "src/auth.ts")).toBe(true); + }); + + test("tsconfig.json does not cover config.json", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/config.json" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Updated tsconfig.json.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "src/config.json")).toBe( + true, + ); + }); + + test("myapi.com does not cover hostname api.com", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url: "https://api.com/v1" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Called myapi.com.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "https://api.com/v1")).toBe( + true, + ); + }); }); describe("verifyOrRepair", () => { @@ -267,16 +343,42 @@ describe("verifyOrRepair", () => { }); test("repairSummary names only what the handoff missed", () => { - const facts = extractContinuationFacts(droppedTurns()); - const repaired = repairSummary( - "Migrating auth to opaque tokens in src/auth.ts.", - facts, - verifyCompactionSummary( - "Migrating auth to opaque tokens in src/auth.ts.", - facts, - ).misses, - ); - expect(repaired).toContain(VERIFY_REPAIR_HEADING); + const facts = { + goal: "Migrate the auth module to opaque tokens", + constraints: [ + "Never use emojis in the handoff.", + "Always keep the public API stable.", + ], + nextAction: "Fix the token refresh assertion next.", + state: "Fix the token refresh assertion next.", + verification: ["bun run test auth", "bun run check"], + blockers: ["token refresh assertion failed", "ECONNREFUSED on staging"], + exactNames: ["src/auth.ts", "packages/runtime/config.json"], + }; + const summary = + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run test " + + "auth; the token refresh assertion failed. Never use emojis in the " + + "handoff. Fix the token refresh assertion next."; + const misses = verifyCompactionSummary(summary, facts).misses; + const kinds = misses.map((m) => m.kind); + expect(kinds).toContain("constraint"); + expect(kinds).toContain("blocker"); + expect(kinds).toContain("verification"); + expect(kinds).toContain("exactName"); + expect(kinds).not.toContain("goal"); + expect(kinds).not.toContain("nextAction"); + const repaired = repairSummary(summary, facts, misses); + const repair = repaired.slice(repaired.indexOf(VERIFY_REPAIR_HEADING)); + expect(repair).toContain("Always keep the public API stable."); + expect(repair).not.toContain("Never use emojis"); + expect(repair).toContain("ECONNREFUSED on staging"); + expect(repair).not.toContain("token refresh assertion failed"); + expect(repair).toContain("bun run check"); + expect(repair).not.toContain("bun run test auth"); + expect(repair).toContain("packages/runtime/config.json"); + expect(repair).not.toContain("src/auth.ts"); + expect(repair).not.toContain("Goal:"); + expect(repair).not.toContain("Next:"); }); test("repair lines are goal, next, exact names, then the rest", () => { @@ -367,7 +469,7 @@ describe("pruning compactor verify pass", () => { }); describe("continuation facts survive many folds", () => { - test("goal, exact path, and next action hold after five lossy folds", async () => { + test("verify signal holds after five lossy folds", async () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 4000, @@ -391,8 +493,9 @@ describe("continuation facts survive many folds", () => { ]; } const text = allText(turns); - expect(text).toContain("opaque tokens"); - expect(text).toContain("auth.ts"); + // The initiating ask is anchored out of summarizedTurns, so "opaque tokens" + // / "auth.ts" survive from that turn and do not prove the verify pass. + // "refresh assertion" is only in dropped tool errors / next action. expect(text).toContain("refresh assertion"); }); }); @@ -474,8 +577,7 @@ describe("completeness gate plus verify repair", () => { (occurrence) => occurrence.provenance === "compaction-handoff", ); expect(handoffs.length).toBeGreaterThan(0); - expect(allText(first.output)).toContain("opaque tokens"); - expect(allText(first.output)).toContain("auth.ts"); + expect(allText(first.output)).toContain("refresh assertion"); const followUp = [ textTurn("user", "follow-up after first fold"), @@ -489,8 +591,6 @@ describe("completeness gate plus verify repair", () => { expect(second.record.reason).not.toBe( "verify failed — keeping prior context", ); - expect(allText(second.output)).toContain("opaque tokens"); - expect(allText(second.output)).toContain("auth.ts"); expect(allText(second.output)).toContain("refresh assertion"); }); }); diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index dbd66e000..a4fe0c2d1 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -305,15 +305,19 @@ export function extractContinuationFacts( // Basename for paths (a summary that moves `src/auth.ts` to "auth.ts" still // names it); hostname for URLs (query strings get reworded freely). +// Match on token/path boundaries so "oauth.ts" does not cover "auth.ts", +// "tsconfig.json" does not cover "config.json", and "myapi.com" does not +// cover hostname "api.com". function exactNameSupported(name: string, summary: string): boolean { const lowered = summary.toLowerCase(); if (name.startsWith("http")) { const host = hostnameOf(name); - if (host !== undefined && lowered.includes(host.toLowerCase())) return true; - return lowered.includes(name.toLowerCase()); + if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) + return true; + return tokenAppears(name.toLowerCase(), lowered); } const base = name.split("/").pop() ?? name; - return base.length > 0 && lowered.includes(base.toLowerCase()); + return base.length > 0 && tokenAppears(base.toLowerCase(), lowered); } // Claims that deny failure while the dropped turns record it. Narrow on @@ -402,17 +406,22 @@ export function repairSummary( if ((kinds.has("blocker") || kinds.has("nextAction")) && !stateCovered) lines.push(`State: ${facts.state}`); if (kinds.has("constraint")) { - lines.push( - `Constraints:\n${facts.constraints.map((c) => `- ${c}`).join("\n")}`, - ); + const missing = misses + .filter((m) => m.kind === "constraint") + .map((m) => m.detail); + lines.push(`Constraints:\n${missing.map((c) => `- ${c}`).join("\n")}`); } if (kinds.has("blocker")) { - lines.push( - `Open blockers:\n${facts.blockers.map((b) => `- ${b}`).join("\n")}`, - ); + const missing = misses + .filter((m) => m.kind === "blocker") + .map((m) => m.detail); + lines.push(`Open blockers:\n${missing.map((b) => `- ${b}`).join("\n")}`); } if (kinds.has("verification")) { - lines.push(`Ran: ${facts.verification.join("; ")}`); + const missing = misses + .filter((m) => m.kind === "verification") + .map((m) => m.detail); + lines.push(`Ran: ${missing.join("; ")}`); } return `${summary.trimEnd()}\n\n${lines.join("\n")}`; } From 00556a4bdb7cb821855840f4fb5c1e5780cd9dda Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:25:47 -0700 Subject: [PATCH 06/11] fix(session): drop dead State repair and tighten token scoring --- src/session/compaction-verify.test.ts | 33 ++++++++++++++++++++++++++- src/session/compaction-verify.ts | 20 +++++++--------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 234d4754c..578e92c63 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -146,12 +146,13 @@ describe("verifyCompactionSummary", () => { expect(kinds).toContain("exactName"); }); - test("auth as a goal token does not match authored, author, or preauth", () => { + test("auth as a goal token does not match authored, author, preauth, or pre-auth", () => { const facts = extractContinuationFacts([textTurn("user", "Fix auth now")]); for (const summary of [ "The authored notes: next step module plan is set.", "The author notes: next step module plan is set.", "The preauth notes: next step module plan is set.", + "The pre-auth notes: next step module plan is set.", ]) { const report = verifyCompactionSummary(summary, facts); expect(report.supported).toBe(false); @@ -168,6 +169,15 @@ describe("verifyCompactionSummary", () => { expect(report.misses.some((m) => m.kind === "contradiction")).toBe(true); }); + test("a half-overlap paraphrase does not cover the standing goal", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "The module tokens migrate elsewhere on schedule.", + facts, + ); + expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + }); + test("oauth.ts does not cover src/auth.ts", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), @@ -379,6 +389,7 @@ describe("verifyOrRepair", () => { expect(repair).not.toContain("src/auth.ts"); expect(repair).not.toContain("Goal:"); expect(repair).not.toContain("Next:"); + expect(repair).not.toMatch(/^State:/m); }); test("repair lines are goal, next, exact names, then the rest", () => { @@ -397,6 +408,26 @@ describe("verifyOrRepair", () => { expect(ranAt).toBeGreaterThan(blockersAt); }); + test("repair does not emit a State line even when state is independent", () => { + const facts = { + goal: "Migrate the auth module to opaque tokens", + constraints: [] as string[], + nextAction: "Fix the token refresh assertion next.", + state: "The cache is still cold after warmup.", + verification: [] as string[], + blockers: ["token refresh assertion failed"], + exactNames: [] as string[], + }; + const summary = "Work continues."; + const misses = verifyCompactionSummary(summary, facts).misses; + expect(misses.some((m) => m.kind === "blocker")).toBe(true); + expect(misses.some((m) => m.kind === "nextAction")).toBe(true); + const repaired = repairSummary(summary, facts, misses); + const repair = repaired.slice(repaired.indexOf(VERIFY_REPAIR_HEADING)); + expect(repair).not.toMatch(/^State:/m); + expect(repair).not.toContain("cache is still cold"); + }); + test("a tight cap keeps goal, next, and names before aborting on the tail", () => { const facts = extractContinuationFacts(droppedTurns()); const misses = verifyCompactionSummary("Work continues.", facts).misses; diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index a4fe0c2d1..7b59ddf1c 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -125,14 +125,18 @@ function significantTokens(text: string, cap = 24): string[] { return out; } -// Half (rounded up) of the fact's content words must appear in the summary. +// A strict majority of the fact's content words must appear in the summary. // Short facts need all of their words: one shared word proves nothing. -// Match on token boundaries so "auth" does not score against "authored". +// Match on token boundaries so "auth" does not score against "authored" or +// hyphenated "pre-auth". function isTokenChar(ch: string | undefined): boolean { if (ch === undefined) return false; const code = ch.charCodeAt(0); return ( - (code >= 48 && code <= 57) || (code >= 97 && code <= 122) || code === 95 + (code >= 48 && code <= 57) || + (code >= 97 && code <= 122) || + code === 95 || + code === 45 ); } @@ -156,7 +160,7 @@ function tokensSupported(fact: string, summary: string): boolean { const lowered = summary.toLowerCase(); const hits = tokens.filter((t) => tokenAppears(t, lowered)).length; const needed = - tokens.length <= 2 ? tokens.length : Math.ceil(tokens.length / 2); + tokens.length <= 2 ? tokens.length : Math.floor(tokens.length / 2) + 1; return hits >= needed; } @@ -397,14 +401,6 @@ export function repairSummary( .map((m) => m.detail); lines.push(`Exact references: ${missing.join(", ")}`); } - // The state line carries where the work stood when it is neither the next - // action nor an already-listed blocker. - const stateCovered = - facts.state.trim().length === 0 || - facts.state === facts.nextAction || - facts.blockers.includes(facts.state); - if ((kinds.has("blocker") || kinds.has("nextAction")) && !stateCovered) - lines.push(`State: ${facts.state}`); if (kinds.has("constraint")) { const missing = misses .filter((m) => m.kind === "constraint") From 25f4ac5101e5227b634760b901de993d2518394b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:45:17 -0700 Subject: [PATCH 07/11] fix(session): treat dotted exactNames as whole tokens Interior dots join filenames and hostnames so vite.config.ts does not cover config.ts. HTTP URLs lowercase before hostname scoring. Drop unused ContinuationFacts.state. --- src/session/compaction-verify.test.ts | 135 +++++++++++++++++++++----- src/session/compaction-verify.ts | 39 ++++---- 2 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 578e92c63..519ee7bfc 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -253,6 +253,119 @@ describe("verifyCompactionSummary", () => { true, ); }); + + test("dotted names are whole tokens so suffixes and prefixes do not cover", () => { + const cases: { + tool: string; + args: Record; + summary: string; + detail: string; + }[] = [ + { + tool: "read_file", + args: { path: "src/config.ts" }, + summary: "Fix auth now. Updated vite.config.ts.", + detail: "src/config.ts", + }, + { + tool: "read_file", + args: { path: "src/test.ts" }, + summary: "Fix auth now. Updated auth.test.ts.", + detail: "src/test.ts", + }, + { + tool: "read_file", + args: { path: "src/auth.ts" }, + summary: "Fix auth now. Updated foo.auth.ts.", + detail: "src/auth.ts", + }, + { + tool: "read_file", + args: { path: "src/auth.ts" }, + summary: "Fix auth now. Kept auth.ts.bak.", + detail: "src/auth.ts", + }, + { + tool: "web_fetch", + args: { url: "https://api.com/v1" }, + summary: "Fix auth now. Called www.api.com.", + detail: "https://api.com/v1", + }, + ]; + for (const { tool, args, summary, detail } of cases) { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: tool, + arguments: args, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary(summary, facts); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === detail)).toBe(true); + } + }); + + test("basename in a path still covers the exact name", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read auth.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(false); + }); + + test("uppercase HTTP URL scores hostname case-insensitively", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url: "HTTP://API.COM/v1" }, + }, + ], + timestamp: 2, + }, + ]); + expect( + verifyCompactionSummary( + "Fix auth now. Called api.com.", + facts, + ).misses.some((m) => m.kind === "exactName"), + ).toBe(false); + expect( + verifyCompactionSummary( + "Fix auth now. Hit endpoint v1.", + facts, + ).misses.some((m) => m.kind === "exactName"), + ).toBe(true); + }); }); describe("verifyOrRepair", () => { @@ -360,7 +473,6 @@ describe("verifyOrRepair", () => { "Always keep the public API stable.", ], nextAction: "Fix the token refresh assertion next.", - state: "Fix the token refresh assertion next.", verification: ["bun run test auth", "bun run check"], blockers: ["token refresh assertion failed", "ECONNREFUSED on staging"], exactNames: ["src/auth.ts", "packages/runtime/config.json"], @@ -389,7 +501,6 @@ describe("verifyOrRepair", () => { expect(repair).not.toContain("src/auth.ts"); expect(repair).not.toContain("Goal:"); expect(repair).not.toContain("Next:"); - expect(repair).not.toMatch(/^State:/m); }); test("repair lines are goal, next, exact names, then the rest", () => { @@ -408,26 +519,6 @@ describe("verifyOrRepair", () => { expect(ranAt).toBeGreaterThan(blockersAt); }); - test("repair does not emit a State line even when state is independent", () => { - const facts = { - goal: "Migrate the auth module to opaque tokens", - constraints: [] as string[], - nextAction: "Fix the token refresh assertion next.", - state: "The cache is still cold after warmup.", - verification: [] as string[], - blockers: ["token refresh assertion failed"], - exactNames: [] as string[], - }; - const summary = "Work continues."; - const misses = verifyCompactionSummary(summary, facts).misses; - expect(misses.some((m) => m.kind === "blocker")).toBe(true); - expect(misses.some((m) => m.kind === "nextAction")).toBe(true); - const repaired = repairSummary(summary, facts, misses); - const repair = repaired.slice(repaired.indexOf(VERIFY_REPAIR_HEADING)); - expect(repair).not.toMatch(/^State:/m); - expect(repair).not.toContain("cache is still cold"); - }); - test("a tight cap keeps goal, next, and names before aborting on the tail", () => { const facts = extractContinuationFacts(droppedTurns()); const misses = verifyCompactionSummary("Work continues.", facts).misses; diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 7b59ddf1c..07357ccd3 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -2,7 +2,7 @@ // // After the compactor writes a summary handoff, this module scores the new // spine against the continuation facts the dropped turns carried (goal, -// state, next action, constraints, verification, blockers, exact names). +// next action, constraints, verification, blockers, exact names). // A fold that drops or contradicts those facts would leave the next agent // without steam, so the pass repairs the handoff deterministically or aborts // the fold. Fail closed: never ship a lying spine. @@ -17,8 +17,6 @@ export const ContinuationFacts = type({ constraints: "string[]", /** Last substantive assistant/user text in the dropped region. */ nextAction: "string", - /** Last error text or assistant snippet: where the work stood. */ - state: "string", /** Verification commands run (shell/test invocations). */ verification: "string[]", /** Errored tool-result texts: what is still broken. */ @@ -127,9 +125,10 @@ function significantTokens(text: string, cap = 24): string[] { // A strict majority of the fact's content words must appear in the summary. // Short facts need all of their words: one shared word proves nothing. -// Match on token boundaries so "auth" does not score against "authored" or -// hyphenated "pre-auth". -function isTokenChar(ch: string | undefined): boolean { +// Match on token boundaries so "auth" does not score against "authored", +// hyphenated "pre-auth", or dotted "foo.auth". An interior `.` joins a +// dotted name; a sentence period does not. +function isNameChar(ch: string | undefined): boolean { if (ch === undefined) return false; const code = ch.charCodeAt(0); return ( @@ -140,14 +139,25 @@ function isTokenChar(ch: string | undefined): boolean { ); } +function isTokenCharAt(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return false; + const ch = text[index]; + if (isNameChar(ch)) return true; + // `.` is a token char only between name chars: `vite.config.ts` stays + // one token, but `tokens.` / `api.com.` still match at a sentence stop. + return ( + ch === "." && isNameChar(text[index - 1]) && isNameChar(text[index + 1]) + ); +} + function tokenAppears(token: string, lowered: string): boolean { let from = 0; for (;;) { const i = lowered.indexOf(token, from); if (i < 0) return false; if ( - !isTokenChar(i === 0 ? undefined : lowered[i - 1]) && - !isTokenChar(lowered[i + token.length]) + !isTokenCharAt(lowered, i - 1) && + !isTokenCharAt(lowered, i + token.length) ) return true; from = i + 1; @@ -257,11 +267,6 @@ export function extractContinuationFacts( const goal = users[0] ?? ""; const nextAction = assistants[assistants.length - 1] ?? users[users.length - 1] ?? ""; - const state = - blockers[blockers.length - 1] ?? - assistants[assistants.length - 1] ?? - users[users.length - 1] ?? - ""; const exactNames: string[] = []; const verification: string[] = []; @@ -298,7 +303,6 @@ export function extractContinuationFacts( goal: goal.slice(0, 500), constraints: extractConstraints(users), nextAction: nextAction.slice(0, 300), - state: state.slice(0, 300), verification: verification.slice(0, 6), blockers, exactNames: exactNames.slice(0, 20), @@ -310,11 +314,12 @@ export function extractContinuationFacts( // Basename for paths (a summary that moves `src/auth.ts` to "auth.ts" still // names it); hostname for URLs (query strings get reworded freely). // Match on token/path boundaries so "oauth.ts" does not cover "auth.ts", -// "tsconfig.json" does not cover "config.json", and "myapi.com" does not -// cover hostname "api.com". +// "vite.config.ts" does not cover "config.ts", and "www.api.com" does not +// cover hostname "api.com". `/` stays a non-token so a path still covers +// its basename. function exactNameSupported(name: string, summary: string): boolean { const lowered = summary.toLowerCase(); - if (name.startsWith("http")) { + if (name.toLowerCase().startsWith("http")) { const host = hostnameOf(name); if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) return true; From 80761518f8918d396981675e188240429ff38bba Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 18:32:47 -0700 Subject: [PATCH 08/11] fix(session): strip needle periods and require http(s) schemes --- src/session/compaction-verify.test.ts | 54 +++++++++++++++++++++++++++ src/session/compaction-verify.ts | 13 +++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 519ee7bfc..94cf05508 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -119,6 +119,13 @@ describe("extractContinuationFacts", () => { const report = verifyCompactionSummary("anything", facts); expect(report.supported).toBe(true); }); + + test("uppercase HTTP URL in user text is lifted into exactNames", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM next"), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + }); }); describe("verifyCompactionSummary", () => { @@ -160,6 +167,28 @@ describe("verifyCompactionSummary", () => { } }); + test("trailing period on a goal needle does not miss an unpunctuated summary", () => { + const facts = extractContinuationFacts([textTurn("user", "Fix auth now.")]); + const report = verifyCompactionSummary("Fix auth now", facts); + expect(report.misses.some((m) => m.kind === "goal")).toBe(false); + }); + + test("trailing period on a constraint needle does not miss an unpunctuated summary", () => { + const facts = { + goal: "Fix auth now", + constraints: ["Never use emojis."], + nextAction: "", + verification: [], + blockers: [], + exactNames: [], + }; + const report = verifyCompactionSummary( + "Fix auth now. Never use emojis", + facts, + ); + expect(report.misses.some((m) => m.kind === "constraint")).toBe(false); + }); + test("denying failure while errors were dropped is a contradiction", () => { const facts = extractContinuationFacts(droppedTurns()); const report = verifyCompactionSummary( @@ -337,6 +366,31 @@ describe("verifyCompactionSummary", () => { expect(report.misses.some((m) => m.kind === "exactName")).toBe(false); }); + test("http/client.ts is covered by basename client.ts", () => { + for (const filePath of ["http/client.ts", "HTTP/Client.ts"]) { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: filePath }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read client.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(false); + } + }); + test("uppercase HTTP URL scores hostname case-insensitively", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 07357ccd3..f0ce24052 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -115,7 +115,11 @@ const STOPWORDS = new Set([ function significantTokens(text: string, cap = 24): string[] { const out: string[] = []; - for (const word of text.toLowerCase().split(/[^a-z0-9_./-]+/)) { + for (const raw of text.toLowerCase().split(/[^a-z0-9_./-]+/)) { + // `.` stays in the splitter so `vite.config.ts` is one token; a sentence + // period still glues to the last word (`now.`, `emojis.`). Strip it from + // the needle so an unpunctuated summary still covers the fact. + const word = raw.replace(/\.+$/, ""); if (word.length < 4 || STOPWORDS.has(word)) continue; if (!out.includes(word)) out.push(word); if (out.length >= cap) break; @@ -292,7 +296,7 @@ export function extractContinuationFacts( } } for (const text of [...users, ...assistants]) { - for (const match of text.match(/https?:\/\/[^\s)]+/g) ?? []) { + for (const match of text.match(/https?:\/\/[^\s)]+/gi) ?? []) { if (!exactNames.includes(match)) exactNames.push(match); } } @@ -319,11 +323,12 @@ export function extractContinuationFacts( // its basename. function exactNameSupported(name: string, summary: string): boolean { const lowered = summary.toLowerCase(); - if (name.toLowerCase().startsWith("http")) { + const loweredName = name.toLowerCase(); + if (loweredName.startsWith("http://") || loweredName.startsWith("https://")) { const host = hostnameOf(name); if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) return true; - return tokenAppears(name.toLowerCase(), lowered); + return tokenAppears(loweredName, lowered); } const base = name.split("/").pop() ?? name; return base.length > 0 && tokenAppears(base.toLowerCase(), lowered); From e4107303da887c17371b8ce2b848c79bcd5d4e39 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 18:46:27 -0700 Subject: [PATCH 09/11] fix(session): strip trailing punct from extracted user-text URLs `new URL` keeps a trailing period in the hostname, so a summary that names api.com misses Call HTTP://API.COM. as an exactName. --- src/session/compaction-verify.test.ts | 21 +++++++++++++++++++++ src/session/compaction-verify.ts | 10 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 94cf05508..ba3ce6f4c 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -126,6 +126,27 @@ describe("extractContinuationFacts", () => { ]); expect(facts.exactNames).toContain("HTTP://API.COM"); }); + + test("sentence-final period is not part of a user-text URL", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM."), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + expect(facts.exactNames).not.toContain("HTTP://API.COM."); + expect( + verifyCompactionSummary("Call api.com", facts).misses.some( + (m) => m.kind === "exactName", + ), + ).toBe(false); + }); + + test("comma glue is not part of a user-text URL", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM, then retry"), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + expect(facts.exactNames).not.toContain("HTTP://API.COM,"); + }); }); describe("verifyCompactionSummary", () => { diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index f0ce24052..22f844e85 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -256,6 +256,13 @@ function hostnameOf(url: string): string | undefined { } } +// User-text URLs sit next to sentence punct (`Call HTTP://API.COM.`). +// `new URL` keeps a trailing `.` in the hostname, so a summary that +// names `api.com` would miss the exactName. +function stripUrlGlue(match: string): string { + return match.replace(/[.,;:!]+$/, ""); +} + /** * Pull the continuation facts out of the turns a fold is about to drop. * Deterministic and total: no fact means nothing to verify, never an abort. @@ -297,7 +304,8 @@ export function extractContinuationFacts( } for (const text of [...users, ...assistants]) { for (const match of text.match(/https?:\/\/[^\s)]+/gi) ?? []) { - if (!exactNames.includes(match)) exactNames.push(match); + const url = stripUrlGlue(match); + if (url.length > 0 && !exactNames.includes(url)) exactNames.push(url); } } From 91adeb552a67912281be9c47fe167d897471e367 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 23:28:03 -0700 Subject: [PATCH 10/11] fix(deadcode): run ts-prune under node for the dead-export gate Bun-hosted ts-prune under-reports unused exports on Linux, so CI treats a still-valid allowlist as stale. Launch the CLI with node. --- scripts/check-dead-exports.test.ts | 14 ++++++++++++++ scripts/check-dead-exports.ts | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/check-dead-exports.test.ts b/scripts/check-dead-exports.test.ts index 348f4b53d..fd51ecb58 100644 --- a/scripts/check-dead-exports.test.ts +++ b/scripts/check-dead-exports.test.ts @@ -20,6 +20,7 @@ import { parseAllowlistText, parseGuardConfig, parseTsPruneLine, + tsPruneSpawn, validateAllowlistEntry, validateAllowlistOwnership, validateAllowlistText, @@ -247,6 +248,19 @@ describe("pinned scan invocation", () => { expect(existsSync(join(repoRoot, config.tsconfig))).toBe(true); }); + test("ts-prune is launched with node, not as a Bun-executed bin", () => { + const spawn = tsPruneSpawn("/repo/node_modules/.bin/ts-prune", [ + "-p", + "tsconfig.json", + ]); + expect(spawn.command).toBe("node"); + expect(spawn.args).toEqual([ + "/repo/node_modules/.bin/ts-prune", + "-p", + "tsconfig.json", + ]); + }); + test("parseGuardConfig rejects an unpinned or empty invocation", () => { const valid = { tsconfig: "tsconfig.json", diff --git a/scripts/check-dead-exports.ts b/scripts/check-dead-exports.ts index 5d212fbc7..6798b0d44 100644 --- a/scripts/check-dead-exports.ts +++ b/scripts/check-dead-exports.ts @@ -272,6 +272,18 @@ export function isGuardPassing(outcome: GuardOutcome): boolean { return outcome.violations.length === 0 && outcome.unused.length === 0; } +// ts-prune's analyzer (ts-morph) under-reports unused exports when the CLI +// runs on Bun: Linux CI then treats the Darwin/Node allowlist as stale +// (1 consumer-less export vs ~230). The bin shebang is `node`, but Bun's +// spawn of that file still executes it with Bun. Always launch the CLI +// with node so the gate matches `node node_modules/ts-prune/lib/index.js`. +export function tsPruneSpawn( + tsPruneBinPath: string, + tsPruneArgs: readonly string[], +): { readonly command: string; readonly args: string[] } { + return { command: "node", args: [tsPruneBinPath, ...tsPruneArgs] }; +} + // Counts the TypeScript files the pinned tsconfig pulls into its program via // tsc --listFilesOnly: the same project ts-prune analyzes. A narrowed // tsconfig (or a moved scan root) shrinks this count, and the gate fails @@ -329,10 +341,16 @@ function main(): void { ); } const rules = parseAllowlistText(allowlistText); - const pruned = spawnSync(tsPruneBin, [...config.tsPruneArgs], { + const prune = tsPruneSpawn(tsPruneBin, config.tsPruneArgs); + const pruned = spawnSync(prune.command, prune.args, { cwd: repoRoot, encoding: "utf8", }); + if (pruned.error !== undefined) { + fail( + `ts-prune failed to start with ${prune.command}: ${pruned.error.message}`, + ); + } if (pruned.status !== 0) { fail(`ts-prune failed:\n${pruned.stderr || pruned.stdout}`); } From 592049e6a5c18ea94b9cebefee43151211bc53fb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 22 Sep 2026 08:23:49 -0700 Subject: [PATCH 11/11] test(session): pin verify facts in the fat handoff file --- src/session/compaction-verify.test.ts | 30 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index ba3ce6f4c..454f9a5f2 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { createPruningCompactor } from "./compactor.js"; import { condenseTurns } from "./summarizer.js"; +import { HANDOFF_LATEST_KEY } from "./compaction-handoff.js"; import { createCompactionArchive, wrapCompactorWithCompletenessGate, @@ -47,6 +48,14 @@ function allText(turns: ConversationTurn[]): string { .join("\n"); } +function handoffFileText(result: { + blobs?: { key: string; bytes: Uint8Array }[]; +}): string { + const blob = result.blobs?.find((b) => b.key === HANDOFF_LATEST_KEY); + if (blob === undefined) return ""; + return new TextDecoder().decode(blob.bytes); +} + // A dropped region with a standing goal, an exact path, a verification // command, and an unresolved failure. function droppedTurns(): ConversationTurn[] { @@ -667,10 +676,12 @@ describe("pruning compactor verify pass", () => { describe("continuation facts survive many folds", () => { test("verify signal holds after five lossy folds", async () => { + let priorFile: string | undefined; const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 4000, summarize: async () => "Work continues. Next: fix tests.", + readPriorHandoff: async () => priorFile, }); let turns: ConversationTurn[] = [ ...droppedTurns(), @@ -683,17 +694,18 @@ describe("continuation facts survive many folds", () => { expect(result.record.reason).not.toBe( "verify failed — keeping prior context", ); + const file = handoffFileText(result); + // The thin live spine does not carry next-action / blocker text; the + // fat handoff file does. Verify repair writes those into the narrative + // that the file persists, and later folds re-read it. + expect(file).toContain("refresh assertion"); + priorFile = file; turns = [ ...result.output, textTurn("user", `follow-up ${fold}`), textTurn("assistant", `progress note ${fold}`), ]; } - const text = allText(turns); - // The initiating ask is anchored out of summarizedTurns, so "opaque tokens" - // / "auth.ts" survive from that turn and do not prove the verify pass. - // "refresh assertion" is only in dropped tool errors / next action. - expect(text).toContain("refresh assertion"); }); }); @@ -751,10 +763,12 @@ describe("completeness gate plus verify repair", () => { test("two lossy folds through the gate keep facts via adopted handoffs", async () => { const archive = memoryArchive(); + let priorFile: string | undefined; const inner = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 4000, summarize: async () => "Work continues. Next: fix tests.", + readPriorHandoff: async () => priorFile, }); const wrapped = wrapCompactorWithCompletenessGate(inner, archive); let turns: ConversationTurn[] = [ @@ -774,7 +788,9 @@ describe("completeness gate plus verify repair", () => { (occurrence) => occurrence.provenance === "compaction-handoff", ); expect(handoffs.length).toBeGreaterThan(0); - expect(allText(first.output)).toContain("refresh assertion"); + const firstFile = handoffFileText(first); + expect(firstFile).toContain("refresh assertion"); + priorFile = firstFile; const followUp = [ textTurn("user", "follow-up after first fold"), @@ -788,7 +804,7 @@ describe("completeness gate plus verify repair", () => { expect(second.record.reason).not.toBe( "verify failed — keeping prior context", ); - expect(allText(second.output)).toContain("refresh assertion"); + expect(handoffFileText(second)).toContain("refresh assertion"); }); });