diff --git a/CHANGELOG.md b/CHANGELOG.md index f94caf2..d68919c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Consolidation follow-ups (task #439): an accepted consolidation note is never a source of the next run (no recurring-fix or chain summaries of summaries; an accepted recurring-fix summary no longer pairs with its own sources), the report counts only newly queued or still-pending candidates and says how many were already reviewed, `amem consolidate --project` refuses a project other than the current one (its repository policy cannot be resolved from here), and acceptance reads one placement proof per locked section instead of four. - On-demand deterministic consolidation proposals and a bounded `amem_evidence` MCP bundle (agent-mem#40 F18/F19). Consolidation writes machine-local candidates, with person-only exact-revision acceptance; curated v1 ops, hashes and paths are unchanged. Candidate readers must understand the new consolidation payload before sharing a home that uses it; older candidate readers refuse the unknown kind. Evidence contains live project-curated citations only, makes no model call, and records only confirmed rendered output. ## 0.7.0 diff --git a/README.md b/README.md index 6972f4f..6128d5c 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ The library exports `buildIntentBrief`, `planGuardNodes`, `matchingPlanFails` an ## Consolidation and evidence -Run `amem consolidate [--project ]` to queue deterministic proposals for `amem review`. It compares a bounded project-local window using indexed text overlap, shared paths and current supersession-chain metadata. It does not edit curated notes. The person reviews each merge, replacement or recurring-failure summary, and acceptance rechecks every source revision. A recurring-failure summary preserves its source failures and decisions. Large source quotations are counted as too large instead of silently shortened into a proposed note. +Run `amem consolidate [--project ]` (another project is refused) to queue deterministic proposals for `amem review`. It compares a bounded project-local window using indexed text overlap, shared paths and current supersession-chain metadata. It does not edit curated notes. The person reviews each merge, replacement or recurring-failure summary, and acceptance rechecks every source revision. A recurring-failure summary preserves its source failures and decisions. Large source quotations are counted as too large instead of silently shortened into a proposed note. The CLI runs the deterministic pass only. A library host may explicitly enable an injected model callback for the person; no provider is built in. It has batch, byte, deadline and persistent daily limits. Model answers remain marked machine-local candidates that cannot be accepted; `amem note add` is the separate path for an independently authored note. A non-answer is never saved. diff --git a/docs/contracts.md b/docs/contracts.md index 308d6e7..c2d8db3 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -416,7 +416,7 @@ agent-mem#40 F14 asks for query expansion from aliases and tags and for BM25 plu ## On-demand consolidation and evidence (F18/F19) -`amem consolidate [--project ]` is a person-only candidate writer, never a curated-memory writer. It screens one project-local snapshot under one guarded policy revision, then uses at most 256 verified-synced live rows and emits at most 32 deterministic proposals. FTS5 overlap plus shared paths suggests a merge; identical text suggests a replacement; a current head's retained supersession metadata gives chain context. FTS unavailability uses literal token overlap and is reported. A pair of related fail notes can propose a recurring-failure summary only when a related actual decision or contract is present. The proposal asks the person to assess that relationship and never invents a fix. Every quoted source is preserved whole; a proposal that cannot fit the candidate/note bounds is counted as oversized. Truncation is explicit. +`amem consolidate [--project ]` is a person-only candidate writer, never a curated-memory writer. It screens one project-local snapshot under one guarded policy revision, then uses at most 256 verified-synced live rows and emits at most 32 deterministic proposals. FTS5 overlap plus shared paths suggests a merge; identical text suggests a replacement; a current head's retained supersession metadata gives chain context. FTS unavailability uses literal token overlap and is reported. A pair of related fail notes can propose a recurring-failure summary only when a related actual decision or contract is present. The proposal asks the person to assess that relationship and never invents a fix. An accepted consolidation note (first line `Accepted consolidation candidate:`) is never a source: it neither seeds a fail or recurring-fix proposal nor a chain summary, so repeated runs do not summarize their own summaries. `--project` must be the current project (the repository policy that narrows screening is the working directory's). The report counts only candidates newly queued or still pending as queued; proposals whose candidate was already accepted or rejected are counted as `alreadyReviewed`. Every quoted source is preserved whole; a proposal that cannot fit the candidate/note bounds is counted as oversized. Truncation is explicit. Consolidation payloads live only in `candidates.db`, with the existing candidate hash/retention and person review flow. Curated shared-v1 schema, ops, hashes and paths do not change. A host with an older candidate reader must be upgraded before this payload is written into a shared home; older readers refuse an unknown candidate kind. Acceptance binds all exact source revisions, home/profile, project, placement and scope, and screens every source and the complete resulting note before appending. A stable host policy revision is required across all source verdicts and before effects. Transport placement comes from verified legacy segment locations (32 MiB read budget) or the generation index, never from tier or View.local. Unknown placement refuses the operation, local sources are excluded and counted, and local or mixed-placement acceptance refuses before any append. Merge and replacement acceptance supersede their sources; recurring-failure summaries keep their sources live. A rejected candidate remains rejected when the same proposal is generated again. diff --git a/src/cli.ts b/src/cli.ts index a67c2c3..9690be3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -232,6 +232,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject(); if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project"); const base = memoryHome(), project = resolveAlias(base, requested), aliases = aliasedKeys(base, project); + // The policy below is narrowed by the repository around the working directory, which is the bound project's. Another project's own repository policy cannot be resolved from here, so screening it with this one would apply the wrong narrowing. + if (project !== boundProject()) throw Error("consolidate --project must name the current project; run it from the other project's directory to consolidate that one"); assertProjectActive(base, project); const store = curatedStore(project, { deferPromotions: true, screen: () => "keep" }); let candidates: CandidateStore | undefined; @@ -240,7 +242,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}): const assertCurrent = () => { binding(); assertProjectActive(base, project); if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n")) throw Error("consolidation project binding changed"); }; candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } }); const report = await proposeConsolidation(store, candidates, { project, actor: "person", policy: policyOf, policyVersion: policyKey, assertCurrent }); - return options["--json"] ? JSON.stringify(report, null, 2) : `${report.candidates.length} consolidation candidates queued for amem review; no curated notes changed${report.truncated ? "; scan truncated" : ""}${report.oversized ? `; ${report.oversized} proposals too large` : ""}${report.localExcluded ? `; ${report.localExcluded} local sources excluded` : ""}`; + return options["--json"] ? JSON.stringify(report, null, 2) : `${report.candidates.length} consolidation candidates queued for amem review; no curated notes changed${report.alreadyReviewed ? `; ${report.alreadyReviewed} already reviewed` : ""}${report.truncated ? "; scan truncated" : ""}${report.oversized ? `; ${report.oversized} proposals too large` : ""}${report.localExcluded ? `; ${report.localExcluded} local sources excluded` : ""}`; } finally { candidates?.close(); store.close(); } } if (sub === "brief") { diff --git a/src/commands-table.ts b/src/commands-table.ts index 9cbd107..ea412f1 100644 --- a/src/commands-table.ts +++ b/src/commands-table.ts @@ -24,7 +24,7 @@ const filters = { ...json, "--tier": { description: "Memory tier", value: ["shor export const GLOBAL_OPTIONS = { "--help": flag("Show help") }; export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json }; export const COMMANDS: Record = { - consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("Explicit active project key (default current project)") } }, + consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } }, setup: { description: "Preview reversible MCP and hook setup for native coding CLIs; apply and undo are person-only", options: { "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "all"] }] }, mcp: { description: "Run the project-bound MCP stdio server for this session" }, hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] }, diff --git a/src/memory/candidates.ts b/src/memory/candidates.ts index a7c7e89..06cf64e 100644 --- a/src/memory/candidates.ts +++ b/src/memory/candidates.ts @@ -10,6 +10,8 @@ import { stripPrivateDeep } from "./private-spans.ts"; import { MemoryStore, type GoalView, type NoteInput, type View } from "./store.ts"; export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000; +/** First line of every note written by accepting a consolidation candidate; `consolidationClusters` reads it to avoid proposing summaries of its own summaries. */ +export const ACCEPTED_CONSOLIDATION_PREFIX = "Accepted consolidation candidate: "; export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const; export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const; export type CandidateVerdict = "public" | "private" | "pii" | "unknown"; @@ -284,14 +286,16 @@ export class CandidateStore { if (context.actor !== "person" || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed"); if (context.policyVersion!() !== policyVersion) throw Error("consolidation policy changed"); }; - const check = () => { + // One placement proof per locked section: `sourcePlacements` (read once inside withCurrentEntries, whose fences stay held) serves every check before the append section; + // the append section reads one fresh proof. Each proof re-reads the legacy snapshot (up to 32 MiB), so four per acceptance was the cost this removes. + const check = (fresh = false) => { policyCurrent(); const current = data.targets.map(target => { const row = context.store.get(target.id, context.project); if (!row || row.id !== target.id || row.project !== context.project || row.supersededBy || candidateTargetRevision(row) !== target.revision) throw Error("consolidation source revision is stale"); return row; }); - const placements = context.store.entryPlacements(data.targets.map(target => target.id), context.project); + const placements = fresh ? context.store.entryPlacements(data.targets.map(target => target.id), context.project) : sourcePlacements; if (current.some(row => row.scope !== current[0]!.scope || placements.get(row.id) !== placements.get(current[0]!.id))) throw Error("consolidation sources must have the same placement and scope"); if (data.targets.some(target => placements.get(target.id) !== sourcePlacements.get(target.id))) throw Error("consolidation source placement changed"); policyCurrent(); // The proof scan can be long; fence policy changes after it, not only before it. @@ -299,12 +303,12 @@ export class CandidateStore { }; const sources = check(), first = sources[0]!; const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: "person", source: "person", about: [...data.about], - text: `Accepted consolidation candidate: ${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` }; + text: `${ACCEPTED_CONSOLIDATION_PREFIX}${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` }; if (note.text.length > 4000) throw Error("accepted consolidation exceeds the v1 note limit"); for (const source of sources) if (context.policy(structuredClone(candidate), structuredClone(source), structuredClone(note)) !== true) throw Error("consolidation refused by current host policy"); check(); return context.store.withAcceptedNote(note, append => { - check(); + check(true); const replay = this.beginAcceptance(candidate.id, context.project, policyCurrent); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true }; const noteId = append(), applied: string[] = []; try { diff --git a/src/memory/consolidate.ts b/src/memory/consolidate.ts index e016074..d261379 100644 --- a/src/memory/consolidate.ts +++ b/src/memory/consolidate.ts @@ -1,4 +1,4 @@ -import { CandidateStore, candidateTargetRevision, type CandidateInput } from "./candidates.ts"; +import { CandidateStore, candidateInputId, candidateTargetRevision, type CandidateInput } from "./candidates.ts"; import { consolidationClusters } from "./consolidation-clusters.ts"; import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts"; import { recallViewIsLive } from "./recall.ts"; @@ -15,6 +15,8 @@ export interface ConsolidationOptions { export interface ConsolidationReport { schema: "amem.consolidation/v1"; project: string; candidates: { id: string; action: string; sources: string[] }[]; engine: "fts5" | "substring"; truncated: boolean; oversized: number; localExcluded: number; + /** Proposals whose candidate already exists as accepted, accepting or rejected: they are not queued again and are not in `candidates`. */ + alreadyReviewed: number; model?: ModelConsolidationReport; } export class ConsolidationProposalError extends Error { @@ -58,11 +60,18 @@ export async function proposeConsolidation(store: MemoryStore, candidates: Candi check(); if (await policy.screen(input)) prepared.push(input); } check(); - const report: ConsolidationReport = { schema: "amem.consolidation/v1", project, candidates: [], engine: clustered.engine, truncated: raw.length === 257 || clustered.truncated, oversized, localExcluded: rows.slice(0, 256).length - synced.length }; + const report: ConsolidationReport = { schema: "amem.consolidation/v1", project, candidates: [], engine: clustered.engine, truncated: raw.length === 257 || clustered.truncated, oversized, localExcluded: rows.slice(0, 256).length - synced.length, alreadyReviewed: 0 }; try { // All asynchronous host work finishes before the first candidate write. The ordinary candidate // queue cap still applies; a full queue reports the ids already enqueued, never a silent batch. - for (const input of prepared) { check(); const id = candidates.put(input, "public"); report.candidates.push({ id, action: input.action, sources: input.targets.map(target => `mem:${target.id}`) }); } + // put() is INSERT OR IGNORE: a candidate the person already accepted or rejected keeps its status, so it is neither written again nor reported as queued. + for (const input of prepared) { + check(); + const known = candidates.get(candidateInputId(input), project); + if (known && known.status !== "pending") { report.alreadyReviewed++; continue; } + const id = candidates.put(input, "public"); + report.candidates.push({ id, action: input.action, sources: input.targets.map(target => `mem:${target.id}`) }); + } if (options.model !== undefined) report.model = await proposeModelConsolidations(store, candidates, synced, options.model, { project, actor: options.actor, check, policy }); } catch { throw new ConsolidationProposalError(report.candidates.map(row => row.id)); } return report; diff --git a/src/memory/consolidation-clusters.ts b/src/memory/consolidation-clusters.ts index 094f85e..8cedac0 100644 --- a/src/memory/consolidation-clusters.ts +++ b/src/memory/consolidation-clusters.ts @@ -1,11 +1,16 @@ import { SearchIndex } from "./search-fts.ts"; import { recallViewIsLive } from "./recall.ts"; +import { ACCEPTED_CONSOLIDATION_PREFIX } from "./candidates.ts"; import type { View } from "./store.ts"; export type ConsolidationAction = "merge" | "supersession" | "recurring-fix"; export interface ConsolidationCluster { action: ConsolidationAction; sources: View[]; reason: string } export const CONSOLIDATION_SOURCE_LIMIT = 256, CONSOLIDATION_PROPOSAL_LIMIT = 32; const words = (row: View) => new Set((`${row.title ?? ""} ${row.text}`.toLowerCase().match(/[\p{L}\p{N}_]{3,}/gu) ?? []).filter(word => !["the", "and", "for", "with", "from", "this", "that", "approach", "symptom", "cause", "evidence", "retry", "when"].includes(word)).slice(0, 128)); +/** A note written by accepting a consolidation candidate. Proposing over one would be a summary of summaries. */ +export const isAcceptedConsolidation = (row: Pick): boolean => row.text.startsWith(ACCEPTED_CONSOLIDATION_PREFIX); +/** An accepted recurring-fix summary keeps its sources live and quotes them whole, so it overlaps each of them. */ +const isAcceptedRecurringFix = (row: Pick): boolean => isAcceptedConsolidation(row) && /^Action: recurring-fix$/m.test(row.text.split("\n\n", 1)[0] ?? ""); const paths = (row: View) => row.about.filter(node => node.startsWith("path:")); const sharedPath = (a: View, b: View) => paths(a).some(node => paths(b).includes(node)); const overlap = (a: Set, b: Set) => [...a].filter(word => b.has(word)).length / Math.max(1, Math.min(a.size, b.size)); @@ -31,15 +36,15 @@ export function consolidationClusters(input: readonly View[], project: string, n for (let i = 0; i < rows.length; i++) { const row = rows[i]!, candidates = index.available ? new Set(index.query([...terms.get(row.id)!].slice(0, 32).join(" "), rows.length, project).map(hit => hit.ref)) : undefined; for (const peer of rows.slice(i + 1)) { - if (row.kind !== peer.kind) continue; + if (row.kind !== peer.kind || isAcceptedRecurringFix(row) || isAcceptedRecurringFix(peer)) continue; const lexical = overlap(terms.get(row.id)!, terms.get(peer.id)!); if (row.text === peer.text) add("supersession", [row, peer], "Identical text; review one replacement for both originals."); else if ((!candidates || candidates.has(peer.id)) && lexical >= 0.5 && sharedPath(row, peer)) add("merge", [row, peer], "Shared path and overlapping indexed terms; no equivalence is assumed."); } // A retained supersession chain is context, never a reason to resurrect an old body. - if (row.supersedes.length >= 2) add("merge", [row], "Current head already replaces multiple originals; review a chain summary."); + if (row.supersedes.length >= 2 && !isAcceptedConsolidation(row)) add("merge", [row], "Current head already replaces multiple originals; review a chain summary."); } - const failRows = rows.filter(row => row.kind === "fail"), decisions = rows.filter(row => row.kind === "decision" || row.kind === "contract"); + const failRows = rows.filter(row => row.kind === "fail" && !isAcceptedConsolidation(row)), decisions = rows.filter(row => row.kind === "decision" || row.kind === "contract"); for (let i = 0; i < failRows.length; i++) for (const peer of failRows.slice(i + 1)) { const fail = failRows[i]!; if (!sharedPath(fail, peer) || overlap(terms.get(fail.id)!, terms.get(peer.id)!) < 0.5) continue; diff --git a/src/memory/store.ts b/src/memory/store.ts index 44ebd53..6cfbe81 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -382,7 +382,10 @@ export class MemoryStore { return work(entries); }); } - /** Exact original transport placement; View.local describes tier, not a verified publication choice. */ + /** + * Exact original transport placement; View.local describes tier, not a verified publication choice. + * ponytail: a legacy (shared-v1) home reads its whole snapshot under a 32 MiB byte budget on every call and refuses past it, so consolidation and its acceptance are unavailable for a legacy home that large. Upgrade: migrate the home to a generation (its placements come from the indexed `generation_rows`), or keep a placement index for legacy homes. + */ entryPlacements(ids: readonly string[], project: string): ReadonlyMap { if (!ids.length || ids.length > 256 || new Set(ids).size !== ids.length || ids.some(id => !/^[a-f0-9]{32}$/.test(id)) || !project.trim()) throw Error("invalid placement batch"); return this.withStoreFences(stores => { diff --git a/test/consolidate-cli.test.ts b/test/consolidate-cli.test.ts index b5320ee..642010c 100644 --- a/test/consolidate-cli.test.ts +++ b/test/consolidate-cli.test.ts @@ -20,6 +20,7 @@ test("consolidate is person-only, queues candidates, and review accepts through await expect(cli(["consolidate"], { stdinTTY: false })).rejects.toThrow("terminal"); const result = JSON.parse(await cli(["consolidate", "--json"])); expect(result.candidates).toHaveLength(1); expect(result.candidates[0].action).toBe("supersession"); + await expect(cli(["consolidate", "--project", "local:somewhere-else"])).rejects.toThrow("must name the current project"); const accepted = JSON.parse(await cli(["review", "accept", result.candidates[0].id, "--kind", "consolidation", "--json"])); expect(accepted.noteId).toMatch(/^[a-f0-9]{32}$/); } finally { if (beforeHome === undefined) delete process.env.AMEM_HOME; else process.env.AMEM_HOME = beforeHome; } diff --git a/test/consolidate.test.ts b/test/consolidate.test.ts index d55d0e5..b39b598 100644 --- a/test/consolidate.test.ts +++ b/test/consolidate.test.ts @@ -63,3 +63,17 @@ test("a merge proposal retains each source task as an explicit about node", asyn expect(input.kind).toBe("consolidation"); if (input.kind !== "consolidation") throw Error("wrong candidate"); expect(input.about).toContain("task:work#1"); expect(input.about).toContain("task:work#2"); }); +test("the report counts only new or still-pending candidates as queued; accepted and rejected ones are already reviewed (task #439)", async () => { + const f = fixture(); f.note("bounded writer lease avoids transaction contention"); f.note("bounded writer lease avoids transaction contention"); + const first = await f.run(); expect(first.candidates).toHaveLength(1); expect(first.alreadyReviewed).toBe(0); + const again = await f.run(); expect(again.candidates).toHaveLength(1); expect(again.alreadyReviewed).toBe(0); // pending: still queued, still reported + f.candidates.reject(first.candidates[0]!.id, { project: f.project, actor: "person", verdict: "public" }); + const rejected = await f.run(); expect(rejected.candidates).toEqual([]); expect(rejected.alreadyReviewed).toBe(1); + expect(f.candidates.list({ project: f.project, status: "all" })).toHaveLength(1); +}); +test("an accepted consolidation does not make the next run propose a summary of it (task #439)", async () => { + const f = fixture(); f.note("bounded writer lease avoids transaction contention"); f.note("bounded writer lease avoids transaction contention"); + const first = await f.run(); + f.candidates.accept(first.candidates[0]!.id, { project: f.project, actor: "person", verdict: "public", store: f.store, policy: () => true, policyVersion: () => "fixed" }); + const next = await f.run(); expect(next.candidates).toEqual([]); expect(next.alreadyReviewed).toBe(0); +}); diff --git a/test/consolidation-acceptance.test.ts b/test/consolidation-acceptance.test.ts index 17a62d0..8794530 100644 --- a/test/consolidation-acceptance.test.ts +++ b/test/consolidation-acceptance.test.ts @@ -121,3 +121,9 @@ test("generation placement refuses a fresh oversized segment before any unbounde finally { SegmentReader.prototype.read = read; } } finally { store.close(); } }); +test("acceptance reads the placement proof once per locked section, not once per check (task #439)", () => { + const f = fixture(), a = f.note("source A"), b = f.note("source B"), id = f.candidates.put(f.proposal([a, b]), "public"); + const original = f.store.entryPlacements.bind(f.store); let calls = 0; + f.store.entryPlacements = (...args) => { calls++; return original(...args); }; + f.accept(id); expect(calls).toBe(2); // the locked read, and the proof inside the append section +}); diff --git a/test/consolidation-clusters.test.ts b/test/consolidation-clusters.test.ts index d566a62..1b246fb 100644 --- a/test/consolidation-clusters.test.ts +++ b/test/consolidation-clusters.test.ts @@ -39,3 +39,19 @@ test("bounded source and proposal windows explicitly report truncation", () => { expect(result.truncated).toBe(true); expect(result.clusters).toHaveLength(CONSOLIDATION_PROPOSAL_LIMIT); expect(result.clusters.flatMap(row => row.sources).every(row => row.id !== rows[256]!.id)).toBe(true); }); +const accepted = (n: number, action: string, extra: Partial = {}) => note(n, { text: `Accepted consolidation candidate: ${"c".repeat(64)}\nAction: ${action}\nSources: mem:x\n\nwriter lease contention blocks transaction`, ...extra }); +test("an accepted recurring-fix summary is not a fail source and does not pair with its own sources (task #439)", () => { + const fails = [note(1), note(2, { text: "writer lease contention blocks second transaction" })], decision = note(3, { kind: "decision", text: "bound writer lease duration before transaction" }); + const first = consolidationClusters([...fails, decision], "project", now).clusters.filter(row => row.action === "recurring-fix"); + expect(first).toHaveLength(1); + const summary = accepted(4, "recurring-fix"); // accepted: a live fail on the same path, quoting its sources + const second = consolidationClusters([...fails, decision, summary], "project", now).clusters; + expect(second.filter(row => row.action === "recurring-fix")).toHaveLength(1); + expect(second.every(row => row.sources.every(source => source.id !== summary.id))).toBe(true); +}); +test("an accepted merge or supersession head is not proposed again as a single-source chain summary (task #439)", () => { + const head = accepted(1, "merge", { kind: "decision", supersedes: [note(8).id, note(9).id] }), other = note(2, { kind: "decision", text: "parser unicode escaping keeps strings intact" }); + expect(consolidationClusters([head, other], "project", now).clusters).toEqual([]); + const plain = note(3, { kind: "decision", supersedes: [note(8).id, note(9).id] }); // a person's own multi-original head still gets the chain summary + expect(consolidationClusters([plain, other], "project", now).clusters.map(row => row.action)).toEqual(["merge"]); +});